feat: DH-21756: Add ui.component memoization and selective re-rendering#1296
feat: DH-21756: Add ui.component memoization and selective re-rendering#1296mofojed wants to merge 35 commits into
Conversation
f383c9d to
e85f1bc
Compare
| ) | ||
| ``` | ||
|
|
||
| --- |
There was a problem hiding this comment.
With this notation we could also write:
memo_parent = ui.memo(parent)
5e1318c to
305fc47
Compare
jnumainville
left a comment
There was a problem hiding this comment.
Just gave some comments on the two options.
| raise TypeError( | ||
| f"@ui.memo can only be used with @ui.component decorated functions. " | ||
| f"Got {type(element).__name__} instead." | ||
| ) |
There was a problem hiding this comment.
I think the fact that we are throwing this error after checking for @ui.component is a point against this option.
A third option would be that @ui.memo creates a ui.component under the hood since it has to be one anyways, but then it would have to duplicate arguments if we add more to ui.component, so more to maintain. I don't love that option either.
There was a problem hiding this comment.
Yea after playing with @ui.memo more, I think it makes the most sense to just do the @ui.component(memo= option.
| - ❌ Two decorators required (more verbose) | ||
| - ❌ Easy to get decorator order wrong (`@ui.component` then `@ui.memo` won't work) |
There was a problem hiding this comment.
Not sure if there is more possible decorators (routers?), but these cons would compound quickly if we did have any others.
There was a problem hiding this comment.
If we thought of the decorators as just wrapper components like React, then the order at least makes intuitive sense
But I'm not sure I have a strong opinion on either syntax
|
|
||
| **Cons:** | ||
|
|
||
| - ❌ Cannot memoize third-party components |
There was a problem hiding this comment.
I'm not sure I understand this con, or at least I don't think it's meaningful? It would be easy enough to take third-party components and put them in your own memoized component without any real problems?
Maybe it's saying you can't do something like ui.memo(external_ui_component, ...) directly, but you can just wrap it in another component, and that isn't substantially more difficult.
There was a problem hiding this comment.
Agreed, the con is minimal
|
|
||
| ## Recommendation | ||
|
|
||
| **Implement both options**, with Option B (`memo=`) as the primary API and Option A (`@ui.memo`) for advanced use cases. |
There was a problem hiding this comment.
I'd say only B is my choice. Easier to maintain, much simpler to use, and I don't think these advanced use cases are really meaningful.
c56be04 to
0241cc8
Compare
|
ui docs preview (Available for 14 days) |
| items_bad = ["apple", "banana"] | ||
|
|
||
| # GOOD: Use use_memo to keep the same reference | ||
| items_good = ui.use_memo(lambda: ["apple", "banana"], []) | ||
|
|
||
| return ui.flex( | ||
| ui.button("Increment", on_press=lambda: set_count(count + 1)), | ||
| ui.text(f"Count: {count}"), | ||
| item_list(items_good), # Won't re-render unnecessarily | ||
| direction="column", | ||
| ) | ||
|
|
||
|
|
||
| app_example = app() | ||
| ``` | ||
|
|
||
| ### Passing Callback Functions | ||
|
|
||
| Lambda functions and inline function definitions create new references each render: | ||
|
|
||
| ```python | ||
| from deephaven import ui | ||
|
|
||
|
|
||
| @ui.component(memo=True) | ||
| def button_row(on_click): | ||
| return ui.button("Click me", on_press=on_click) | ||
|
|
||
|
|
||
| @ui.component | ||
| def app(): | ||
| count, set_count = ui.use_state(0) | ||
|
|
||
| # BAD: Creates a new function reference every render | ||
| # handle_click_bad = lambda: print("clicked") | ||
|
|
||
| # GOOD: Use use_callback to memoize the function | ||
| handle_click_good = ui.use_callback(lambda: print("clicked"), []) |
There was a problem hiding this comment.
Inconsistent in presentation, line is left in, vs commented out
There was a problem hiding this comment.
Opted to leave the line in, since it's assigning to a different variable name.
|
ui docs preview (Available for 14 days) |
17e0d28 to
1ff45d9
Compare
|
ui docs preview (Available for 14 days) |
1ff45d9 to
6188a0b
Compare
|
ui docs preview (Available for 14 days) |
2f3ed22 to
79da6df
Compare
|
ui docs preview (Available for 14 days) |
Two options for props-based memoization to skip re-renders: - Option A: @ui.memo decorator (familiar to React devs) - Option B: @ui.component(memo=True|compare_fn) parameter (cleaner) Includes: - API design and implementation details - MemoizedFunctionElement and Renderer changes - Unit tests for both options - Performance benchmarks - Comparison and recommendation (implement both)
- Checks props and if they're the same, just return the previously rendered node
- Still need to clean up the `_default_are_props_equal` and how children are handled, I think?
- Also need to add a bunch of unit tests. But it more or less works!
```
from deephaven import ui
def are_props_equal(old_props, new_props):
print(f"Checking props {old_props} vs {new_props}")
return old_props == new_props
@ui.component
def foo_component(name):
value, set_value = ui.use_state(0)
print(f"foo {name} render")
return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1))
@ui.memo(are_props_equal=are_props_equal)
@ui.component
def memo_foo_component(name):
value, set_value = ui.use_state(0)
print(f"memo_foo {name} render")
return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1))
memo_foo = ui.memo()(foo_component)
@ui.component
def bar_component():
value, set_value = ui.use_state(0)
return ui.flex(
foo_component("A"),
foo_component("B"),
memo_foo_component("X"),
memo_foo("Y"),
ui.button(f"bar {value}", on_press=lambda: set_value(value+1))
)
mf = memo_foo_component("mf")
b = bar_component()
```
| def test_shallow_equal(self): | ||
| # Identical primitive values are equal | ||
| self.assertTrue(shallow_equal(1, 1)) | ||
| self.assertTrue(shallow_equal("hello", "hello")) | ||
| self.assertTrue(shallow_equal(None, None)) | ||
| self.assertTrue(shallow_equal(True, True)) | ||
| self.assertTrue(shallow_equal(1.5, 1.5)) |
| Don't use `memo` when: | ||
|
|
||
| - The component's props change on almost every render | ||
| - The component is cheap to render | ||
| - You're prematurely optimizing without measuring performance |
There was a problem hiding this comment.
Should maybe add something about a component having side effects. In general side effects that are independent of props should be avoided (and can cause other issues). Probably just worth noting and might be a good pitfall example too. I think I've seen some customer code where they're modifying some global var, so make sure that isn't render specific and is tied to prop/state changes at least
Side effects like that are a code smell/not considered valid in React since they're impure rendering functions
| # Good candidate: renders same static content while parent updates | ||
| @ui.component(memo=True) | ||
| def expensive_chart(data): | ||
| # Imagine this does complex data processing | ||
| return ui.text(f"Chart with {len(data)} points") |
There was a problem hiding this comment.
Is there a better example that wouldn't be also solved by use_memo? If you had some heavy data processing (like calculate the Nth prime number where N is the prop), I would say just stick it in use_memo.
We use it pretty sparsely in web-client-ui and it's mostly for lots of children or things that end up as heavy DOM paints it seems.
| ## Custom Comparison Function | ||
|
|
||
| By default, `memo=True` uses shallow equality to compare props. You can provide a custom comparison function by passing it directly to `memo`: |
There was a problem hiding this comment.
I think this section should have a similar warning that the React docs has. It mentions it's very rare to need this, be careful of functions, and ensure you don't try to deep check something with a ton of recursion
| def compare_by_id(prev_props, next_props): | ||
| """Only re-render if the 'id' prop changes.""" | ||
| return prev_props.get("id") == next_props.get("id") | ||
|
|
||
|
|
||
| @ui.component(memo=compare_by_id) | ||
| def user_card(id, name, last_updated): | ||
| return ui.flex( | ||
| ui.text(f"User #{id}"), | ||
| ui.text(f"Name: {name}"), | ||
| ui.text(f"Updated: {last_updated}"), | ||
| direction="column", |
There was a problem hiding this comment.
I'm not sure how I feel about this example. The React docs say you should still compare every prop. This seems like an example which shows how it works, but not a good reason to do so. At least it should probably say something like "Not recommended. For illustrative purposes only"
The React example is an array of datapoints you can compare, but the array object may change.
Mostly with the increase in LLM usage, don't want it to ingest this page and think this example is what you should do
|
|
||
| ### Deep Equality Comparison | ||
|
|
||
| For props containing nested data structures, you might want deep equality: |
There was a problem hiding this comment.
Warning about comparing deeply nested structures can get very expensive
| if not is_dirty_render: | ||
| # Don't open the context | ||
| return _render_list_in_open_context(item, context, is_dirty_render) |
There was a problem hiding this comment.
What does opening the context do here that we want to avoid it? Also, the function seems like a misnomer consider we don't open the context we pass in
There was a problem hiding this comment.
Yea should probably rename it.
So we don't want to open the context in that we're not actually going to render anything at this level, since we want to use the cached results. We don't want it to open the context, think it hasn't rendered anything, then close it, throwing away the previous results (and liveness scopes).
We still may need to render children though, so we still iterate through the results and fetch the child contexts from this context. I'll think about a way to clean this up.
| needs_render = is_dirty_render | ||
|
|
||
| if isinstance(element, MemoizedElement): | ||
| needs_render = not element.are_props_equal(prev_props) |
There was a problem hiding this comment.
Should the memoized element check also include a is_dirty_render check?
A dirty render is specifically if this element had a state change? Or if it or any of its parents did? Trying to understand which part is selective re-rendering and which is memoization
There was a problem hiding this comment.
We check if the render context.is_dirty below, which detects state changes in the memoized component.
There was a problem hiding this comment.
I think I'm still a little confused on is_dirty_render vs context.is_dirty here. Either way, we probably don't need to always check are_props_equal if it's a memoized component.
If we know the state changed in the memoized component, checking are_props_equal is just a waste because we will just ignore it. So I think there should be some additional check to avoid running the equality check when we already know it doesn't matter
You could also have is_dirty_render = True, but needs_render = False if are_props_equal = False. Not sure what case that is or if it causes a potential bug case
There was a problem hiding this comment.
is_dirty_render is just from the parent rendering. So this component may not need to render since it's memoized.
| if not needs_render and not context.is_dirty: | ||
| logger.debug("Returning cached element %s", element.name) | ||
| rendered_props = _render_dict_in_open_context( | ||
| prev_rendered_element_props, context, False | ||
| ) | ||
| return RenderedNode(element.name, rendered_props) |
There was a problem hiding this comment.
This is confusing to me. It doesn't need a render, it's clean, but we seem to render it anyway? Does the cache not store the result of the render already?
There was a problem hiding this comment.
Added a comment to try and clarify.
| props = element.render() | ||
| logger.debug("Rendering element %s", element.name) | ||
|
|
||
| rendered_element_props = element.render() |
There was a problem hiding this comment.
I see the comments all mention that render returns "rendered props". Is that true? It seems odd to me b/c I thought the render returned the representation of the element?
Naming things is hard
There was a problem hiding this comment.
Yes naming things is hard... the render() returns a list of props for the element, and we then just pass those props to the client to actually render the component with those props. Made a comment on Element.render() to help clarify.
| realized Document tree for the Element provided. | ||
|
|
||
| Key points: | ||
| - The Renderer executes Element.render() within a RenderContext to generate the realized Document tree. |
There was a problem hiding this comment.
This furthers my confusion about calling things "rendered props". This is more what I expect render does
|
ui docs preview (Available for 14 days) |
| from .._internal import ( | ||
| is_iterable, | ||
| get_component_qualname, | ||
| dict_shallow_equal, | ||
| iterable_shallow_equal, | ||
| ) |
| if "children" in prev_props or "children" in next_props: | ||
| prev_children = prev_props.get("children") | ||
| next_children = next_props.get("children") | ||
|
|
||
| # Compare iterable children element-wise using shallow semantics. | ||
| if is_iterable(prev_children) and is_iterable(next_children): | ||
| if not iterable_shallow_equal(prev_children, next_children): | ||
| return False | ||
| else: | ||
| # For non-iterables, compare by value. | ||
| if prev_children != next_children: | ||
| return False |
| 4. With custom comparison function: | ||
| @ui.component(memo=lambda prev, next: prev["value"] == next["value"]) | ||
| def my_component(value, on_click): | ||
| return ui.button(str(value), on_press=on_click) |
| # Primitives: equal but non-identical values should be equal (compared by value). | ||
| # int("1000") is outside CPython's small-int cache, so the two ints are | ||
| # distinct objects (different identity) but equal in value. | ||
| big1 = int("1000") | ||
| big2 = int("1000") | ||
| self.assertIsNot(big1, big2) | ||
| self.assertTrue(dict_shallow_equal({"count": big1}, {"count": big2})) | ||
|
|
||
| # Equal but non-identical strings should also be equal | ||
| str1 = "hello world!".upper() | ||
| str2 = "hello world!".upper() | ||
| self.assertIsNot(str1, str2) | ||
| self.assertTrue(dict_shallow_equal({"msg": str1}, {"msg": str2})) | ||
|
|
| # Primitives are compared by value | ||
| big1 = int("1000") | ||
| big2 = int("1000") | ||
| self.assertIsNot(big1, big2) | ||
| self.assertTrue(iterable_shallow_equal([big1, "x"], [big2, "x"])) |
| # Equal but non-identical primitives are equal (compared by value). | ||
| # int("1000") is outside CPython's small-int cache, so the two ints are | ||
| # distinct objects (different identity) but equal in value. | ||
| big1 = int("1000") | ||
| big2 = int("1000") | ||
| self.assertIsNot(big1, big2) | ||
| self.assertTrue(shallow_equal(big1, big2)) | ||
|
|
||
| str1 = "hello world!".upper() | ||
| str2 = "hello world!".upper() | ||
| self.assertIsNot(str1, str2) | ||
| self.assertTrue(shallow_equal(str1, str2)) |
|
ui docs preview (Available for 14 days) |
…before fetch_only assertions - Updated test to follow fetch_only semantics by priming child context with initial dirty render before non-dirty render assertions - Previously the test tried to use fetch_only on a non-existent child context, which is invalid behavior
- Rewrote 'How It Works' section to clarify memoization only applies to props - Rewrote 'When to Use memo' with emphasis that memo is rare optimization - Replaced weak example with 'activity_feed' demonstrating large subtree benefit - Added [!WARNING] box on custom comparison functions with guidance - Removed problematic deep-equality examples - Added 'Side Effects During Rendering' pitfall section - Fixed code examples to avoid markdown parser issues - Removed obsolete snapshot files that no longer match documentation - Added docker-compose.docs-snapshots.override.yml to .gitignore for local rootless Docker support
|
ui docs preview (Available for 14 days) |
| def mark_clean(self) -> None: | ||
| """ | ||
| Mark this context as clean. Used for testing to reset the dirty state after a render. | ||
| """ | ||
| self._is_dirty = False |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
ui docs preview (Available for 14 days) |
1 similar comment
|
ui docs preview (Available for 14 days) |
|
ui docs preview (Available for 14 days) |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
ui docs preview (Available for 14 days) |
|
ui docs preview (Available for 14 days) |
memoparameter to@ui.componentto memoize a component, or pass a custom memoization function for checking behaviour