diff --git a/plans/component-memoization.md b/plans/component-memoization.md new file mode 100644 index 000000000..47cc1ba96 --- /dev/null +++ b/plans/component-memoization.md @@ -0,0 +1,1436 @@ +# Component Memoization Plan: Props-Based Re-render Skip + +## Overview + +This plan describes implementing props-based memoization for `deephaven.ui` components, allowing child components to skip re-rendering when their props haven't changed — even when their parent re-renders. This is similar to React's `React.memo()` functionality. + +**Current behavior:** When a parent component re-renders, all children re-render (unless they have dirty descendant optimization, which only helps when state changes are deep in the tree, not when the parent itself re-renders). + +**Proposed behavior:** Memoized components compare their props; if unchanged, they return cached output and skip re-rendering. + +--- + +## Option A: Separate `@ui.memo` Decorator + +### API Design + +```python +@ui.memo +@ui.component +def my_component(value: int, label: str): + """This component will skip re-rendering if value and label are unchanged.""" + return ui.text(f"{label}: {value}") +``` + +The `@ui.memo` decorator wraps a component (created by `@ui.component`) to add props comparison. + +### Implementation + +#### A1. Create `memo.py` + +```python +# plugins/ui/src/deephaven/ui/components/memo.py + +from __future__ import annotations +import functools +from typing import Any, Callable, Optional +from ..elements import MemoizedFunctionElement, FunctionElement + + +def memo( + component_or_compare: Callable[..., Any] + | Callable[[dict, dict], bool] + | None = None, + *, + compare: Callable[[dict, dict], bool] | None = None, +): + """ + Memoize a component to skip re-rendering when props are unchanged. + + Can be used in several ways: + + 1. Basic usage (shallow comparison): + @ui.memo + @ui.component + def my_component(value): + return ui.text(str(value)) + + 2. With custom comparison function: + @ui.memo(compare=lambda prev, next: prev["value"] == next["value"]) + @ui.component + def my_component(value, on_click): + return ui.button(str(value), on_press=on_click) + + Args: + component_or_compare: Either the component function (when used without parentheses), + or a comparison function (deprecated usage). + compare: Custom comparison function that receives (prev_props, next_props) + and returns True if they are equal (should skip re-render). + If None, uses shallow equality comparison. + + Returns: + A memoized component that skips re-rendering when props are unchanged. + """ + + def create_memo_wrapper(component: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(component) + def memo_wrapper(*args: Any, key: str | None = None, **kwargs: Any): + # Get the FunctionElement from the component + element = component(*args, key=key, **kwargs) + + if not isinstance(element, FunctionElement): + raise TypeError( + f"@ui.memo can only be used with @ui.component decorated functions. " + f"Got {type(element).__name__} instead." + ) + + # Wrap in MemoizedFunctionElement which tracks props for comparison + return MemoizedFunctionElement( + element, + props={"args": args, "kwargs": kwargs}, + compare=compare, + ) + + return memo_wrapper + + # Handle both @ui.memo and @ui.memo() and @ui.memo(compare=...) + if component_or_compare is None: + # Called as @ui.memo() or @ui.memo(compare=...) + return create_memo_wrapper + elif callable(component_or_compare) and compare is None: + # Check if it looks like a comparison function (takes 2 args) or component + import inspect + + sig = inspect.signature(component_or_compare) + params = list(sig.parameters.values()) + + # Heuristic: comparison functions have exactly 2 positional params + if len(params) == 2 and all( + p.default == inspect.Parameter.empty for p in params + ): + # Ambiguous - could be compare function or 2-arg component + # Assume it's used as @ui.memo (without parens) wrapping a component + pass + + # Called as @ui.memo (without parentheses) + return create_memo_wrapper(component_or_compare) + else: + raise TypeError("Invalid usage of @ui.memo") +``` + +#### A2. Create `MemoizedFunctionElement` + +```python +# Add to plugins/ui/src/deephaven/ui/elements/MemoizedFunctionElement.py + +from __future__ import annotations +from typing import Any, Callable, Optional +from .FunctionElement import FunctionElement +from .Element import PropsType +from .._internal import RenderContext + + +def _shallow_equal(prev: dict, next: dict) -> bool: + """Check if two prop dictionaries are shallowly equal.""" + if prev.keys() != next.keys(): + return False + for key in prev: + if prev[key] is not next[key]: + return False + return True + + +class MemoizedFunctionElement(FunctionElement): + """ + A FunctionElement wrapper that memoizes based on props comparison. + + When rendered, compares current props with previously-cached props. + If props are equal (via shallow comparison or custom compare function), + returns cached output without re-executing the render function. + """ + + def __init__( + self, + wrapped_element: FunctionElement, + props: dict[str, Any], + compare: Callable[[dict, dict], bool] | None = None, + ): + """ + Create a memoized function element. + + Args: + wrapped_element: The FunctionElement to wrap. + props: The props passed to this component (args + kwargs). + compare: Custom comparison function, or None for shallow equality. + """ + super().__init__( + wrapped_element.name, + wrapped_element._render, + key=wrapped_element.key, + ) + self._props_for_memo = props + self._compare = compare or _shallow_equal + + @property + def props_for_memo(self) -> dict[str, Any]: + """The props to use for memoization comparison.""" + return self._props_for_memo + + @property + def compare_fn(self) -> Callable[[dict, dict], bool]: + """The comparison function for props.""" + return self._compare +``` + +#### A3. Modify Renderer to check memoized props + +Update `_render_element` in `Renderer.py`: + +```python +def _render_element(element: Element, context: RenderContext) -> RenderedNode: + """Render an Element, potentially reusing cached output for clean components.""" + logger.debug("Rendering element %s in context %s", element.name, context) + + is_function_element = isinstance(element, FunctionElement) + is_memoized = isinstance(element, MemoizedFunctionElement) + + # Check if we can skip rendering this component + if is_function_element and context._cached_rendered_node is not None: + # Memoized component: check props comparison + if is_memoized and context._cached_props_for_memo is not None: + if element.compare_fn( + context._cached_props_for_memo, element.props_for_memo + ): + # Props are equal - skip re-render entirely + logger.debug( + "Skipping memoized render for %s - props unchanged", element.name + ) + return context._cached_rendered_node + + # Existing dirty-tracking optimization + if not context._is_dirty: + if not context._has_dirty_descendant: + logger.debug("Skipping render for %s - using cached node", element.name) + return context._cached_rendered_node + else: + logger.debug("Re-rendering children only for %s", element.name) + return _render_children_only(context) + + # Full re-render needed + # ... existing code ... + + # After render, cache memoization props if applicable + if is_memoized: + context._cached_props_for_memo = element.props_for_memo +``` + +### Examples + +```python +import deephaven.ui as ui + +# Example 1: Basic memoization +@ui.memo +@ui.component +def expensive_chart(data: list[float], title: str): + """Only re-renders when data or title change.""" + # Expensive computation here + return ui.view(ui.heading(title), ui.text(f"Sum: {sum(data)}")) + + +# Example 2: Memoize with custom comparison (ignore callback props) +@ui.memo(compare=lambda prev, next: prev["kwargs"]["value"] == next["kwargs"]["value"]) +@ui.component +def counter_display(value: int, on_increment): + """Re-renders only when value changes, ignores callback changes.""" + return ui.flex( + ui.text(f"Count: {value}"), ui.button("Increment", on_press=on_increment) + ) + + +# Example 3: Parent that causes child re-renders +@ui.component +def parent(): + count, set_count = ui.use_state(0) + items, set_items = ui.use_state(["a", "b", "c"]) + + # Without @ui.memo, expensive_chart would re-render when count changes + # With @ui.memo, it only re-renders when items change + return ui.flex( + ui.button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), + expensive_chart(items, "My Chart"), + ) +``` + +--- + +## Option B: Parameter on `@ui.component` + +### API Design + +```python +@ui.component(memo=True) +def my_component(value: int, label: str): + """This component will skip re-rendering if value and label are unchanged.""" + return ui.text(f"{label}: {value}") + + +# Or with custom comparison function: +@ui.component(memo=lambda prev, next: prev["value"] == next["value"]) +def my_component(value: int, on_click): + return ui.button(str(value), on_press=on_click) +``` + +The `memo` parameter accepts: + +- `True`: Enable memoization with shallow equality comparison (default behavior) +- A callable `(prev_props, next_props) -> bool`: Custom comparison function that returns `True` if props are equal (should skip re-render) + +### Implementation + +#### B1. Modify `make_component.py` + +```python +# plugins/ui/src/deephaven/ui/components/make_component.py + +from __future__ import annotations +import functools +import logging +from typing import Any, Callable, Optional, Union, overload +from .._internal import get_component_qualname +from ..elements import FunctionElement, MemoizedFunctionElement + +logger = logging.getLogger(__name__) + + +def _shallow_equal(prev: dict, next: dict) -> bool: + """Check if two prop dictionaries are shallowly equal.""" + if prev.keys() != next.keys(): + return False + for key in prev: + if prev[key] is not next[key]: + return False + return True + + +# Type alias for comparison functions +CompareFunction = Callable[[dict, dict], bool] + + +@overload +def make_component(func: Callable[..., Any]) -> Callable[..., FunctionElement]: + """Basic usage without parentheses.""" + ... + + +@overload +def make_component( + func: None = None, + *, + memo: Union[bool, CompareFunction] = False, +) -> Callable[[Callable[..., Any]], Callable[..., FunctionElement]]: + """Usage with parameters.""" + ... + + +def make_component( + func: Callable[..., Any] | None = None, + *, + memo: bool | CompareFunction = False, +): + """ + Create a FunctionalElement from the passed in function. + + Args: + func: The function to create a FunctionalElement from. + Runs when the component is being rendered. + memo: Enable memoization to skip re-rendering when props are unchanged. + - False (default): No memoization, component always re-renders with parent. + - True: Enable memoization with shallow equality comparison. + - Callable: Custom comparison function (prev_props, next_props) -> bool + that returns True if props are equal (should skip re-render). + """ + # Determine if memoization is enabled and what comparison function to use + if memo is False: + enable_memo = False + compare_fn = None + elif memo is True: + enable_memo = True + compare_fn = _shallow_equal + elif callable(memo): + enable_memo = True + compare_fn = memo + else: + raise TypeError( + f"memo must be True, False, or a callable, got {type(memo).__name__}" + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., FunctionElement]: + @functools.wraps(fn) + def make_component_node(*args: Any, key: str | None = None, **kwargs: Any): + component_type = get_component_qualname(fn) + + if enable_memo: + element = FunctionElement( + component_type, lambda: fn(*args, **kwargs), key=key + ) + return MemoizedFunctionElement( + element, + props={"args": args, "kwargs": kwargs}, + compare=compare_fn, + ) + else: + return FunctionElement( + component_type, lambda: fn(*args, **kwargs), key=key + ) + + return make_component_node + + if func is not None: + # Called without parentheses: @ui.component + return decorator(func) + else: + # Called with parentheses: @ui.component() or @ui.component(memo=True) + return decorator +``` + +### Examples + +```python +import deephaven.ui as ui + +# Example 1: Basic memoized component (shallow comparison) +@ui.component(memo=True) +def expensive_chart(data: list[float], title: str): + """Only re-renders when data or title change.""" + return ui.view(ui.heading(title), ui.text(f"Sum: {sum(data)}")) + + +# Example 2: Memoized component with custom comparison function +@ui.component( + memo=lambda prev, next: prev["kwargs"]["value"] == next["kwargs"]["value"] +) +def counter_display(value: int, on_increment): + """Re-renders only when value changes, ignores callback changes.""" + return ui.flex( + ui.text(f"Count: {value}"), ui.button("Increment", on_press=on_increment) + ) + + +# Example 3: Mixed usage - non-memoized parent with memoized child +@ui.component +def parent(): + count, set_count = ui.use_state(0) + items, set_items = ui.use_state(["a", "b", "c"]) + + return ui.flex( + ui.button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), + expensive_chart(items, "My Chart"), # Won't re-render when count changes + ) + + +# Example 4: Using ui.use_callback for stable callback references +@ui.component +def parent_with_callbacks(): + count, set_count = ui.use_state(0) + + # Stable callback reference + handle_increment = ui.use_callback(lambda _: set_count(count + 1), [count]) + + return ui.flex( + ui.text(f"Parent count: {count}"), + counter_display(value=count, on_increment=handle_increment), + ) +``` + +--- + +## Comparison of Options + +| Aspect | Option A: `@ui.memo` | Option B: `memo=` param | +| --------------------------- | ---------------------------------------------- | ----------------------------------------- | +| **Similarity to React** | Very similar (`React.memo()`) | Similar naming, integrated into decorator | +| **Explicitness** | Clear separation of concerns | Single decorator, less visual clutter | +| **Discoverability** | Users familiar with React will look for `memo` | `memo` param is intuitive | +| **Flexibility** | Can wrap third-party components | Only works at definition time | +| **Code Readability** | Two decorators can be verbose | Single decorator is cleaner | +| **Backwards Compatibility** | Fully compatible (new API) | Fully compatible (optional parameter) | +| **Custom Comparison** | `@ui.memo(compare=...)` | `@ui.component(memo=compare_fn)` | +| **Decorator Order** | Must be `@ui.memo` then `@ui.component` | N/A | + +### Pros & Cons + +#### Option A: `@ui.memo` + +**Pros:** + +- ✅ Familiar to React developers +- ✅ Can potentially wrap existing components (third-party or legacy) +- ✅ Clear semantic: "this component is memoized" +- ✅ Separation of concerns: component definition vs optimization + +**Cons:** + +- ❌ Two decorators required (more verbose) +- ❌ Easy to get decorator order wrong (`@ui.component` then `@ui.memo` won't work) +- ❌ Slightly more complex implementation (need to wrap FunctionElement) + +#### Option B: `memo=` Parameter + +**Pros:** + +- ✅ Single decorator, cleaner syntax +- ✅ Impossible to get wrong (no decorator ordering) +- ✅ Uses same `memo` terminology as React, making intent clear +- ✅ All component config in one place +- ✅ Single param for both enabling and custom comparison (`memo=True` or `memo=fn`) + +**Cons:** + +- ❌ Cannot memoize third-party components +- ❌ Slightly less explicit than a separate decorator + +--- + +## Recommendation + +**Implement both options**, with Option B (`memo=`) as the primary API and Option A (`@ui.memo`) for advanced use cases. + +### Rationale: + +1. **Option B is simpler for common cases**: Most users just want to optimize their own components. A single decorator with `memo=True` is cleaner and less error-prone. + +2. **Option A enables advanced patterns**: Being able to wrap existing components is valuable for: + + - Optimizing third-party components + - Applying memoization conditionally + - Migrating codebases incrementally + +3. **Both share implementation**: The `MemoizedFunctionElement` and comparison logic are shared, so supporting both is low additional cost. + +### Suggested Default: + +For `@ui.component(memo=True)`, use **shallow equality comparison** by default. This matches React's `React.memo()` behavior and works well when: + +- Props are primitives (int, str, float, bool) +- Props are the same object references (e.g., from `use_state`, `use_memo`) + +For callbacks, recommend using `ui.use_callback()` (if not already available, implement it) to create stable references. + +--- + +## Implementation Plan + +### Phase 1: Core Infrastructure + +| Task | File | Effort | +| ----------------------------------------------- | ------------------------------------- | ------ | +| Create `MemoizedFunctionElement` class | `elements/MemoizedFunctionElement.py` | Low | +| Add `_cached_props_for_memo` to `RenderContext` | `_internal/RenderContext.py` | Low | +| Update `_render_element` for memoization check | `renderer/Renderer.py` | Medium | +| Implement `_shallow_equal` utility | `_internal/utils.py` | Low | + +### Phase 2: Option B - `memo` Parameter + +| Task | File | Effort | +| ----------------------------------------- | ------------------------------ | ------ | +| Update `make_component` with `memo` param | `components/make_component.py` | Medium | +| Update type hints and docstrings | `components/make_component.py` | Low | +| Export from `__init__.py` | `components/__init__.py` | Low | + +### Phase 3: Option A - `@ui.memo` Decorator + +| Task | File | Effort | +| ------------------------------------------- | ------------------------ | ------ | +| Create `memo.py` with decorator | `components/memo.py` | Medium | +| Export `memo` from `components/__init__.py` | `components/__init__.py` | Low | + +### Phase 4: Testing + +| Task | Effort | +| -------------------------------------------------- | ------ | +| Unit tests for `_shallow_equal` | Low | +| Unit tests for memoization skipping re-render | Medium | +| Unit tests for custom comparison function | Medium | +| Unit tests showing props change triggers re-render | Medium | +| Integration tests with nested memoized components | Medium | +| Tests for decorator order error handling | Low | + +### Phase 5: Performance Benchmarks + +| Task | Effort | +| --------------------------------------------- | ------ | +| Benchmark: large list with memoized items | Medium | +| Benchmark: deep tree with memoized branches | Medium | +| Compare memoized vs non-memoized render times | Low | + +--- + +## Unit Tests + +### Test File: `test_memoization.py` + +```python +from __future__ import annotations +from unittest.mock import Mock +from typing import Any, Callable, List +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoizationTestCase(BaseTestCase): + """Tests for component memoization (props-based re-render skip).""" + + def test_memo_skips_rerender_with_same_props(self): + """Test that @ui.memo skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.memo + @ui.component + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + # Pass same value to child regardless of parent state + return ui.flex( + ui.button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (change parent state) + # Find the button and click it + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_rerenders_when_props_change(self): + """Test that @ui.memo re-renders when props change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_value_ref = [None] + + @ui.memo + @ui.component + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + set_value_ref[0] = set_value + return memoized_child(value=value) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change the prop value + set_value_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) + + def test_memo_param_skips_rerender(self): + """Test that @ui.component(memo=True) skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + return ui.flex( + ui.button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_param_with_custom_compare(self): + """Test that @ui.component(memo=compare_fn) uses custom comparison.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_callback_ref = [None] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev["kwargs"]["value"] == next["kwargs"]["value"] + + @ui.component(memo=compare_only_value) + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + set_callback_ref[0] = set_count + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return child_with_callback(value=42, on_click=callback) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback) + set_callback_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (value unchanged) + + def test_memo_with_custom_compare(self): + """Test that custom compare function controls memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_callback_ref = [None] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev["kwargs"]["value"] == next["kwargs"]["value"] + + @ui.memo(compare=compare_only_value) + @ui.component + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + set_callback_ref[0] = set_count + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return child_with_callback(value=42, on_click=callback) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback) + set_callback_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (value unchanged) + + # Now actually change value via parent mechanism + # This would require changing the prop value, which we're not doing here + # So child should remain at render count 1 + + def test_memo_with_object_props(self): + """Test memoization behavior with object props (reference equality).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.memo + @ui.component + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + # Same list object each time + shared_list = [1, 2, 3] + + @ui.component + def parent_with_shared_list(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=shared_list), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent_with_shared_list()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + renderer.render(parent_with_shared_list()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (same list reference) + + def test_memo_with_new_object_props(self): + """Test that memoization re-renders with new object references.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.memo + @ui.component + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + @ui.component + def parent_with_new_list(): + state, set_state = ui.use_state(0) + # Creates new list object each render + items = [1, 2, 3] + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent_with_new_list()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + renderer.render(parent_with_new_list()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (new list reference) + + def test_memo_integration_with_dirty_tracking(self): + """Test that memoization works correctly with existing dirty tracking.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_count = [0] + set_child_state_ref = [None] + + @ui.memo + @ui.component + def memoized_parent(value: int): + parent_count[0] += 1 + child_state, set_child_state = ui.use_state("initial") + set_child_state_ref[0] = set_child_state + return ui.text(f"{value}: {child_state}") + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.button(str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1)), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + + # Change state within memoized component (dirty tracking should work) + set_child_state_ref[0]("updated") + renderer.render(grandparent()) + self.assertEqual( + grandparent_count[0], 1 + ) # Grandparent clean (has dirty descendant) + self.assertEqual(parent_count[0], 2) # Parent re-rendered (own state dirty) + + # Now trigger grandparent re-render with same props to memoized_parent + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 2) # Grandparent re-rendered + self.assertEqual(parent_count[0], 2) # Parent SKIPPED (props unchanged) + + def _find_node(self, root, name): + """Helper to find a node by name in the rendered tree.""" + from deephaven.ui.renderer.RenderedNode import RenderedNode + + if root.name == name: + return root + children = root.props.get("children", []) if root.props else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") + + +class ShallowEqualTestCase(BaseTestCase): + """Tests for shallow equality comparison function.""" + + def test_equal_primitives(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertTrue( + shallow_equal( + {"a": 1, "b": "hello", "c": True}, {"a": 1, "b": "hello", "c": True} + ) + ) + + def test_different_primitives(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse(shallow_equal({"a": 1}, {"a": 2})) + + def test_same_object_reference(self): + from deephaven.ui._internal.utils import shallow_equal + + obj = [1, 2, 3] + self.assertTrue(shallow_equal({"items": obj}, {"items": obj})) + + def test_different_object_reference(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse( + shallow_equal( + {"items": [1, 2, 3]}, + {"items": [1, 2, 3]}, # Same content, different object + ) + ) + + def test_different_keys(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse(shallow_equal({"a": 1}, {"a": 1, "b": 2})) + + def test_none_values(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertTrue(shallow_equal({"a": None}, {"a": None})) + self.assertFalse(shallow_equal({"a": None}, {"a": 0})) +``` + +--- + +## Performance Benchmarks + +### Test File: `test_memoization_benchmarks.py` + +```python +from __future__ import annotations +import time +from unittest.mock import Mock +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoizationBenchmarkTestCase(BaseTestCase): + """Performance benchmarks for component memoization.""" + + def test_benchmark_large_list_without_memo(self): + """Benchmark: re-rendering a large list of non-memoized components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + render_counts = {} + + @ui.component + def list_item(item_id: int): + render_counts[item_id] = render_counts.get(item_id, 0) + 1 + return ui.text(f"Item {item_id}") + + @ui.component + def list_container(): + state, set_state = ui.use_state(0) + items = list(range(100)) # 100 items + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + *[list_item(i, key=str(i)) for i in items], + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + start = time.perf_counter() + result = renderer.render(list_container()) + initial_time = time.perf_counter() - start + + # Verify all rendered + total_renders = sum(render_counts.values()) + self.assertEqual(total_renders, 100) + render_counts.clear() + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + start = time.perf_counter() + renderer.render(list_container()) + rerender_time = time.perf_counter() - start + + # All items re-render (no memoization) + total_rerenders = sum(render_counts.values()) + self.assertEqual(total_rerenders, 100) + + print( + f"\n[No Memo] Initial: {initial_time*1000:.2f}ms, Re-render: {rerender_time*1000:.2f}ms" + ) + print(f"[No Memo] Items re-rendered: {total_rerenders}/100") + + def test_benchmark_large_list_with_memo(self): + """Benchmark: re-rendering a large list of memoized components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + render_counts = {} + + @ui.memo + @ui.component + def memoized_list_item(item_id: int): + render_counts[item_id] = render_counts.get(item_id, 0) + 1 + return ui.text(f"Item {item_id}") + + @ui.component + def list_container(): + state, set_state = ui.use_state(0) + items = list(range(100)) # 100 items + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + *[memoized_list_item(i, key=str(i)) for i in items], + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + start = time.perf_counter() + result = renderer.render(list_container()) + initial_time = time.perf_counter() - start + + # Verify all rendered + total_renders = sum(render_counts.values()) + self.assertEqual(total_renders, 100) + render_counts.clear() + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + start = time.perf_counter() + renderer.render(list_container()) + rerender_time = time.perf_counter() - start + + # NO items should re-render (memoized, props unchanged) + total_rerenders = sum(render_counts.values()) + self.assertEqual(total_rerenders, 0) + + print( + f"\n[With Memo] Initial: {initial_time*1000:.2f}ms, Re-render: {rerender_time*1000:.2f}ms" + ) + print(f"[With Memo] Items re-rendered: {total_rerenders}/100") + + def test_benchmark_deep_tree_memo(self): + """Benchmark: memoized components in deep tree with sibling state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + expensive_render_count = [0] + set_sibling_state_ref = [None] + + @ui.memo + @ui.component + def expensive_component(data: list): + expensive_render_count[0] += 1 + # Simulate expensive computation + total = sum(data) + return ui.text(f"Total: {total}") + + @ui.component + def sibling_with_state(): + state, set_state = ui.use_state(0) + set_sibling_state_ref[0] = set_state + return ui.text(f"Sibling: {state}") + + @ui.component + def parent(): + # Shared data that doesn't change + shared_data = ui.use_memo(lambda: list(range(1000)), []) + return ui.flex( + sibling_with_state(), + expensive_component(data=shared_data), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(expensive_render_count[0], 1) + + # Change sibling state multiple times + for i in range(10): + set_sibling_state_ref[0](i + 1) + renderer.render(parent()) + + # Expensive component should NOT have re-rendered + self.assertEqual(expensive_render_count[0], 1) + print( + f"\n[Deep Tree Memo] Expensive component renders after 10 sibling updates: {expensive_render_count[0]}" + ) + + def test_benchmark_memo_speedup_measurement(self): + """Measure actual speedup from memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + ITERATIONS = 100 + + # Test without memo + @ui.component + def child_no_memo(value: int): + return ui.text(f"Value: {value}") + + @ui.component + def parent_no_memo(): + state, set_state = ui.use_state(0) + return ui.flex(*[child_no_memo(i, key=str(i)) for i in range(50)]) + + rc1 = RenderContext(on_change, on_queue) + renderer1 = Renderer(rc1) + + # Warm up + renderer1.render(parent_no_memo()) + + start = time.perf_counter() + for _ in range(ITERATIONS): + renderer1.render(parent_no_memo()) + no_memo_time = time.perf_counter() - start + + # Test with memo + @ui.memo + @ui.component + def child_with_memo(value: int): + return ui.text(f"Value: {value}") + + @ui.component + def parent_with_memo(): + state, set_state = ui.use_state(0) + return ui.flex(*[child_with_memo(i, key=str(i)) for i in range(50)]) + + rc2 = RenderContext(on_change, on_queue) + renderer2 = Renderer(rc2) + + # Warm up + renderer2.render(parent_with_memo()) + + start = time.perf_counter() + for _ in range(ITERATIONS): + renderer2.render(parent_with_memo()) + memo_time = time.perf_counter() - start + + speedup = no_memo_time / memo_time if memo_time > 0 else float("inf") + + print(f"\n[Speedup Benchmark] {ITERATIONS} iterations, 50 children each") + print( + f" Without memo: {no_memo_time*1000:.2f}ms total ({no_memo_time*1000/ITERATIONS:.3f}ms per render)" + ) + print( + f" With memo: {memo_time*1000:.2f}ms total ({memo_time*1000/ITERATIONS:.3f}ms per render)" + ) + print(f" Speedup: {speedup:.1f}x faster") + + # Assert meaningful speedup (at least 2x) + self.assertGreater( + speedup, 2.0, "Memoization should provide at least 2x speedup" + ) + + def _find_node(self, root, name): + """Helper to find a node by name in the rendered tree.""" + from deephaven.ui.renderer.RenderedNode import RenderedNode + + if root.name == name: + return root + children = root.props.get("children", []) if root.props else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") +``` + +--- + +## RenderContext Updates + +Add to `RenderContext.__init__`: + +```python +self._cached_props_for_memo: Optional[dict[str, Any]] = None +""" +Cached props used for memoization comparison. +Only populated for MemoizedFunctionElement components. +""" +``` + +--- + +## Edge Cases to Handle + +1. **First render**: No cached props, must always render +2. **Key changes**: New key = new context = no cached props +3. **Component unmount/remount**: Cached props cleared when context is deleted +4. **Mixed memoized/non-memoized siblings**: Each maintains own state +5. **Nested memoized components**: Each level checks independently +6. **Custom compare returning wrong type**: Should raise or coerce to bool + +--- + +## Selective Re-rendering Scenarios + +A key challenge with memoization is ensuring that child components with internal state can still re-render when their state changes, even when their memoized parent is skipped. This requires selective re-rendering: the ability to re-render specific subtrees without re-rendering ancestor components. + +### Problem Statement + +When a memoized component's props haven't changed, the renderer returns the cached rendered node. However, if a child component within that memoized component has dirty state (state that changed), the child needs to re-render. The current implementation does not handle this case - the child never re-renders because the memoized parent short-circuits the entire subtree. + +**Bug identified**: `test_memo_child_with_internal_state` demonstrates this issue - when a memoized parent is skipped, child components with dirty state do not re-render. + +### Test Scenario: Grandparent with Memoized and Unmemoized Parents + +Consider this component tree: + +``` +Grandparent (has state) +├── MemoizedParent (memo=True, renders child) +│ └── ChildA (has state) +└── UnmemoizedParent (renders child) + └── ChildB (has state) +``` + +```python +@ui.component +def child_a(): + count, set_count = ui.use_state(0) + return ui.action_button(f"ChildA: {count}", on_press=lambda _: set_count(count + 1)) + + +@ui.component +def child_b(): + count, set_count = ui.use_state(0) + return ui.action_button(f"ChildB: {count}", on_press=lambda _: set_count(count + 1)) + + +@ui.component(memo=True) +def memoized_parent(prop_value: int): + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + +@ui.component +def unmemoized_parent(prop_value: int): + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + +@ui.component +def grandparent(): + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(prop_value=42), # Always receives same prop + unmemoized_parent(prop_value=gp_state), # Receives changing prop + ) +``` + +### Scenario 1: Grandparent state changes but does NOT affect MemoizedParent's props + +**Action**: Click Grandparent's button (changes `gp_state` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ✅ Yes | Its state changed | +| MemoizedParent | ❌ No | Props unchanged (`prop_value=42`) | +| ChildA | ❌ No | Parent skipped, child state unchanged | +| UnmemoizedParent | ✅ Yes | Not memoized, parent re-rendered | +| ChildB | ✅ Yes | Parent re-rendered | + +### Scenario 2: Grandparent state changes AND affects MemoizedParent's props + +**Setup modification**: `memoized_parent(prop_value=gp_state)` (props now depend on grandparent state) + +**Action**: Click Grandparent's button (changes `gp_state` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ✅ Yes | Its state changed | +| MemoizedParent | ✅ Yes | Props changed (`prop_value` 0→1) | +| ChildA | ✅ Yes | Parent re-rendered | +| UnmemoizedParent | ✅ Yes | Not memoized, parent re-rendered | +| ChildB | ✅ Yes | Parent re-rendered | + +### Scenario 3: Child state changes (within memoized parent) + +**Action**: Click ChildA's button (changes ChildA's internal `count` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ❌ No | Its state unchanged | +| MemoizedParent | ❌ No | Props unchanged | +| ChildA | ✅ Yes | **Its own state changed** | +| UnmemoizedParent | ❌ No | Parent unchanged | +| ChildB | ❌ No | Its state unchanged | + +**This is the bug**: Currently, ChildA does NOT re-render because MemoizedParent's memoization check short-circuits before checking if ChildA has dirty state. + +### Required Fix + +The renderer must support selective re-rendering of dirty descendants even when a memoized parent is skipped. This requires two changes: + +#### 1. Propagate re-renders down to dirty children + +When a child context is dirty and re-renders, all of its children (and children's children) must also re-render **unless** they are memoized and not dirty themselves. This ensures that: + +- Dirty components always re-render +- Non-memoized children of re-rendered parents always re-render (current behavior) +- Memoized children can still skip if their props haven't changed and they're not dirty + +#### 2. Add `get_existing_child_context` to RenderContext + +When a memoized component is skipped (props unchanged, not dirty), it still needs to check if any of its children need re-rendering. To do this, the renderer must: + +1. Iterate over the cached rendered node's children +2. For each child that is an Element, call `_render_child_item` but use a new `get_existing_child_context` method instead of `get_child_context` + +The new `get_existing_child_context` method: + +- Returns the existing child context for the given key +- **Throws** if the context doesn't exist (this would indicate a bug - the child should have been rendered before) +- **Does NOT** add the key to `_collected_contexts` (since we're not re-rendering the parent, we don't want to affect its context collection) + +```python +def get_existing_child_context(self, key: ContextKey) -> "RenderContext": + """ + Get an existing child context for the given key. + + Unlike get_child_context, this: + - Throws if the context doesn't exist + - Does NOT add the key to _collected_contexts + + Used when a memoized parent is skipped but we need to check/render dirty children. + """ + return self._children_context[key] +``` + +#### Renderer changes + +In `_render_element`, when a MemoizedElement's props are equal and context is not dirty: + +```python +if ( + prev_rendered_node is not None + and prev_props is not None + and not context.is_dirty + and element.are_props_equal(prev_props) +): + # Memoized component can be skipped, but we still need to render dirty children + # Use a special render pass that only traverses existing child contexts + _render_dirty_children(prev_rendered_node, context) + return prev_rendered_node +``` + +The `_render_dirty_children` function would: + +1. Walk the cached rendered node tree +2. For each child Element, get the existing child context +3. If the child context is dirty or has dirty descendants, re-render that subtree +4. Otherwise, recursively check that child's children + +This approach ensures: + +- Memoized parents don't re-execute their render function when props are unchanged +- Dirty children within memoized parents still re-render correctly +- Context collection remains correct (we don't add contexts that weren't actually rendered) + +--- + +## Documentation Updates Needed + +1. Add `@ui.memo` to public API docs +2. Add `memo=` parameter to `@ui.component` docs +3. Add "Performance Optimization" guide explaining: + - When to use memoization + - How shallow comparison works + - Best practices (stable references, `use_memo`, `use_callback`) + - Gotchas (new objects on each render) diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md new file mode 100644 index 000000000..cbc3e42a9 --- /dev/null +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -0,0 +1,319 @@ +# Memoizing Components + +The `memo` parameter on `@ui.component` optimizes component rendering by skipping re-renders when a component's props haven't changed. This is similar to [React.memo](https://react.dev/reference/react/memo) and is useful for improving performance in components that render often with the same props. + +> [!NOTE] +> The `memo` parameter is for memoizing entire components. To memoize a value or computation within a component, use the [`use_memo`](../hooks/use_memo.md) hook instead. + +## Basic Usage + +Add `memo=True` to your component to skip re-renders when props are unchanged: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def greeting(name): + print(f"Rendering greeting for {name}") + return ui.text(f"Hello, {name}!") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + greeting("World"), # Won't re-render when count changes + direction="column", + ) + + +app_example = app() +``` + +In this example, clicking the button increments `count`, causing `app` to re-render. However, `greeting` will not re-render because its prop (`"World"`) hasn't changed. + +## How It Works + +By default, when a parent component re-renders, all of its child components re-render too. With `memo=True`, `deephaven.ui` compares the new props with the previous props using shallow equality. If all props are equal, the component skips rendering and reuses its previous result. + +The render cycle with memoization: + +1. **Trigger**: Parent component state changes +2. **Render**: Parent re-renders, but memoized children with unchanged props are skipped +3. **Commit**: Only changed parts of the UI are updated + +## When to Use `memo` + +Use `memo=True` when: + +- A component renders often with the same props +- A component is expensive to render (complex calculations, many children) +- A parent component re-renders frequently but passes stable props to children + +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 + +```python +from deephaven import ui + + +# 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") + + +# Not a good candidate: props change every render +@ui.component +def live_counter(count): + return ui.text(f"Count: {count}") + + +@ui.component +def dashboard(): + count, set_count = ui.use_state(0) + chart_data = [1, 2, 3, 4, 5] # Static data + + return ui.flex( + ui.button("Update", on_press=lambda: set_count(count + 1)), + live_counter(count), # No benefit from memo - count always changes + expensive_chart(chart_data), # Benefits from memo - data is stable + direction="column", + ) + + +dashboard_example = dashboard() +``` + +## 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`: + +```python +from deephaven import ui + + +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", + ) + + +@ui.component +def user_profile(): + name, set_name = ui.use_state("Alice") + timestamp, set_timestamp = ui.use_state("12:00") + + return ui.flex( + ui.button("Update timestamp", on_press=lambda: set_timestamp("12:01")), + ui.button("Change name", on_press=lambda: set_name("Bob")), + # Only re-renders if id changes, not name or last_updated + user_card(id=1, name=name, last_updated=timestamp), + direction="column", + ) + + +user_profile_example = user_profile() +``` + +The custom comparison function receives two dictionaries: + +- `prev_props`: The props from the previous render +- `next_props`: The props for the current render + +Return `True` to skip re-rendering (props are "equal"), or `False` to re-render. + +### Deep Equality Comparison + +For props containing nested data structures, you might want deep equality: + +```python +from deephaven import ui + + +def deep_equal(prev_props, next_props): + """Compare props using deep equality.""" + import json + + return json.dumps(prev_props, sort_keys=True) == json.dumps( + next_props, sort_keys=True + ) + + +@ui.component(memo=deep_equal) +def data_display(config): + return ui.text(f"Config: {config}") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + # Even though a new dict is created each render, deep_equal + # will detect the values are the same and skip re-rendering + data_display(config={"setting": "value", "enabled": True}), + direction="column", + ) + + +app_example = app() +``` + +### Threshold-Based Comparison + +You can implement more sophisticated comparison logic: + +```python +from deephaven import ui + + +def significant_change(prev_props, next_props, threshold=5): + """Only re-render if value changes by more than threshold.""" + prev_value = prev_props.get("value", 0) + next_value = next_props.get("value", 0) + return abs(next_value - prev_value) <= threshold + + +@ui.component(memo=significant_change) +def progress_bar(value): + return ui.progress_bar(value=value, label=f"{value}%") + + +@ui.component +def app(): + value, set_value = ui.use_state(0) + + return ui.flex( + ui.button("+1", on_press=lambda: set_value(value + 1)), + ui.button("+10", on_press=lambda: set_value(value + 10)), + # Only re-renders when value changes by more than 5 + progress_bar(value=value), + direction="column", + ) + + +app_example = app() +``` + +## Syntax Options + +The `memo` parameter accepts different values: + +```python skip-test +# Memoization disabled (default behavior) +@ui.component +def my_component(prop): + return ui.text(prop) + + +# Memoization with shallow comparison +@ui.component(memo=True) +def my_memoized_component(prop): + return ui.text(prop) + + +# Memoization with custom comparison function +@ui.component(memo=my_custom_compare) +def my_component_custom(prop): + return ui.text(prop) +``` + +## Common Pitfalls + +### Creating New Objects in Props + +When you pass a new object, list, or dictionary as a prop, it will always be a different reference, causing re-renders even if the content is the same: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def item_list(items): + return ui.flex(*[ui.text(item) for item in items], direction="column") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + # ❌ Creates a new list on every render + # item_list will re-render every time even though content is the same + items_bad = ["apple", "banana"] + + # ✅ 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) + + # ❌ Creates a new function reference every render + handle_click_bad = lambda: print("clicked") + + # ✅ Use use_callback to memoize the function + handle_click_good = ui.use_callback(lambda: print("clicked"), []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + button_row(on_click=handle_click_good), # Won't re-render unnecessarily + direction="column", + ) + + +app_example = app() +``` + +## Comparison with `use_memo` + +| Feature | `memo` parameter | `use_memo` | +| ------- | ----------------------------- | ---------------------- | +| Purpose | Skip re-rendering a component | Cache a computed value | +| Usage | Parameter on `@ui.component` | Hook inside component | +| Input | Component props | Dependencies array | +| Output | Memoized component | Memoized value | + +Use `memo=True` on `@ui.component` to optimize component rendering. Use `use_memo` to optimize expensive calculations within a component. diff --git a/plugins/ui/docs/add-interactivity/render-cycle.md b/plugins/ui/docs/add-interactivity/render-cycle.md index 1355ccb7b..ec8ac6138 100644 --- a/plugins/ui/docs/add-interactivity/render-cycle.md +++ b/plugins/ui/docs/add-interactivity/render-cycle.md @@ -125,3 +125,39 @@ clock_example = clock_wrapper() This works because during this last step, React only updates the content of `ui.header` with the new time. It sees that the `ui.text_field` appears in the JSX in the same place as last time, so React doesn’t touch the `ui.text_field` or its value. After rendering is done and React updated the DOM, the browser will repaint the screen. + +## Optimizing Re-renders with `memo` + +By default, when any component's state changes, `deephaven.ui` re-renders the entire component tree from the root—not just the component that triggered the change or its children, but every component in the tree. This is usually not a problem, but if you have a deeply nested tree or expensive components, you can optimize performance by using the `memo` parameter on `@ui.component`. + +The `memo` parameter tells `deephaven.ui` to skip re-rendering a component when its props haven't changed: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def expensive_child(value): + # This component will only re-render when `value` changes + return ui.text(f"Value: {value}") + + +@ui.component +def parent(): + count, set_count = ui.use_state(0) + static_value = "hello" + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + # This child won't re-render when count changes because static_value stays the same + expensive_child(static_value), + ) + + +parent_example = parent() +``` + +In this example, clicking the button updates `count`, which causes `parent` to re-render. However, `expensive_child` will skip re-rendering because its `value` prop (`"hello"`) hasn't changed. + +For more details on when and how to use memoization effectively, see [Memoizing Components](./memoizing-components.md). diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 41a688a24..28f1e490a 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -81,6 +81,10 @@ "label": "Render Cycle", "path": "add-interactivity/render-cycle.md" }, + { + "label": "Memoizing Components", + "path": "add-interactivity/memoizing-components.md" + }, { "label": "State as a Snapshot", "path": "add-interactivity/state-as-a-snapshot.md" diff --git a/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json b/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json new file mode 100644 index 000000000..f42ae3e5f --- /dev/null +++ b/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"dashboard_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Update"}},{"__dhElemName":"__main__.live_counter","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}}}},{"__dhElemName":"__main__.expensive_chart","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Chart with 5 points"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.dashboard"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json b/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json new file mode 100644 index 000000000..d611dd4bc --- /dev/null +++ b/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}},{"__dhElemName":"__main__.greeting","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Hello, World!"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}},":log":{"type":"Log","data":"Rendering greeting for World\n"}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/265aca0bf0a66998beed2557d84a8071.json b/plugins/ui/docs/snapshots/265aca0bf0a66998beed2557d84a8071.json new file mode 100644 index 000000000..21b6f0e74 --- /dev/null +++ b/plugins/ui/docs/snapshots/265aca0bf0a66998beed2557d84a8071.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"__main__.button_row","props":{"children":{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Click me"}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json b/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json new file mode 100644 index 000000000..dba9c3dd3 --- /dev/null +++ b/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"__main__.data_display","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Config: {'setting': 'value', 'enabled': True}"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5a4c416492c51c1607b3bd59fee8ce36.json b/plugins/ui/docs/snapshots/5a4c416492c51c1607b3bd59fee8ce36.json new file mode 100644 index 000000000..3ecffb814 --- /dev/null +++ b/plugins/ui/docs/snapshots/5a4c416492c51c1607b3bd59fee8ce36.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}},{"__dhElemName":"__main__.item_list","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["apple"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["banana"],"slot":"text"}}]}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json b/plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json new file mode 100644 index 000000000..755a30372 --- /dev/null +++ b/plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"+1"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"+10"}},{"__dhElemName":"__main__.progress_bar","props":{"children":{"__dhElemName":"deephaven.ui.components.ProgressBar","props":{"size":"L","labelPosition":"top","label":"0%","value":0,"minValue":0,"maxValue":100}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json b/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json new file mode 100644 index 000000000..2dc88198b --- /dev/null +++ b/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json @@ -0,0 +1 @@ +{"file":"add-interactivity/render-cycle.md","objects":{"parent_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}},{"__dhElemName":"__main__.expensive_child","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Value: hello"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.parent"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json b/plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json new file mode 100644 index 000000000..3163c77be --- /dev/null +++ b/plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"user_profile_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Update timestamp"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Change name"}},{"__dhElemName":"__main__.user_card","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["User #1"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Name: Alice"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Updated: 12:00"],"slot":"text"}}]}}}}]}}},"__dhElemName":"__main__.user_profile"},"state":"{\"state\": {\"0\": \"Alice\", \"1\": \"12:00\"}}"}}}} \ No newline at end of file diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index e67160444..e514e3459 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -87,7 +87,7 @@ class ValueWithLiveness(Generic[T]): def _value_or_call( - value: T | None | Callable[[], T | None] + value: T | None | Callable[[], T | None], ) -> ValueWithLiveness[T | None]: """ Creates a wrapper around the value, or invokes a callable to hold the value and the liveness scope @@ -253,6 +253,16 @@ class RenderContext: Flag to indicate if this context is mounted. It is unusable after being unmounted. """ + _is_dirty: bool + """ + Flag to indicate if this context is dirty, e.g. state has changed. This is used to determine if a component needs to be re-rendered. + """ + + _cache: Any + """ + A value that can be used to store arbitrary data for this context. + """ + def __init__(self, root: RootRenderContextProtocol): """ Create a new render context. @@ -273,6 +283,8 @@ def __init__(self, root: RootRenderContextProtocol): self._open_context_cleanups = [] self._top_level_scope = None self._is_mounted = True + self._is_dirty = True + self._cache = None def __del__(self): logger.debug("Deleting context") @@ -334,6 +346,9 @@ def open(self) -> Generator[RenderContext, None, None]: cleanup() self._open_context_cleanups = [] + # Reset the dirty state before processing effects, so that any state changes in effects will mark the context as dirty for the next render. + self._is_dirty = False + # Release all child contexts that are no longer referenced for context_key in old_contexts: if context_key not in self._collected_contexts: @@ -430,6 +445,24 @@ def set_url(self, url: str) -> None: """ self._root.set_url(url) + @property + def is_dirty(self) -> bool: + """ + Get whether this context is dirty, e.g. state has changed since the last render. + + Returns: + True if this context is dirty, False otherwise. + """ + return self._is_dirty + + def mark_dirty(self) -> None: + """ + Mark this context as dirty so that it (and its children) are re-rendered on + the next render pass. Used for changes that are not tracked as component + state, such as a URL change, which can affect any component in the tree. + """ + self._is_dirty = True + def has_state(self, key: StateKey) -> bool: """ Check if the given key is in the state. @@ -493,12 +526,29 @@ def update_state(): # This is not the initial state, queue up the state change on the render loop self._root.on_change(update_state) + self._is_dirty = True - def get_child_context(self, key: ContextKey) -> "RenderContext": + def get_child_context( + self, key: ContextKey, fetch_only: bool = False + ) -> "RenderContext": """ Get the child context for the given key. + + Args: + key: The key of the child context to get. + fetch_only: If True, only return an existing context without creating + a new one or adding it to collected contexts. Raises KeyError if + the context doesn't exist. + + Returns: + The child context for the given key. + + Raises: + KeyError: If fetch_only is True and the context doesn't exist. """ logger.debug("Getting child context for key %s", key) + if fetch_only: + return self._children_context[key] if key not in self._children_context: child_context = RenderContext(self._root) logger.debug( @@ -617,6 +667,7 @@ def import_state(self, state: dict[str, Any]) -> None: """ self._state.clear() self._children_context.clear() + self._is_dirty = True if "state" in state: for key, value in state["state"].items(): @@ -652,3 +703,23 @@ def unmount(self) -> None: self._collected_effects.clear() self._collected_unmount_listeners.clear() self._collected_contexts.clear() + + @property + def cache(self) -> Any: + """ + Get the cache for this context. This can be used to store arbitrary data for this context. + + Returns: + The cache for this context. + """ + return self._cache + + @cache.setter + def cache(self, value: Any) -> None: + """ + Set the cache for this context. + + Args: + value: The value to set the cache to. + """ + self._cache = value diff --git a/plugins/ui/src/deephaven/ui/_internal/__init__.py b/plugins/ui/src/deephaven/ui/_internal/__init__.py index 036fa017c..2abbde34a 100644 --- a/plugins/ui/src/deephaven/ui/_internal/__init__.py +++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py @@ -25,6 +25,7 @@ dict_to_camel_case, dict_to_react_props, remove_empty_keys, + dict_shallow_equal, wrap_callable, ) from .RootRenderContextProtocol import ( diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index c88f8fdde..bc4959683 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -1,5 +1,17 @@ from __future__ import annotations -from typing import Any, Callable, Dict, List, Set, Tuple, cast, Sequence, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Set, + Tuple, + cast, + Sequence, + TypeVar, + Union, +) from deephaven.dtypes import ( Instant as DTypeInstant, ZonedDateTime as DTypeZonedDateTime, @@ -949,3 +961,19 @@ def is_iterable(value: Any) -> bool: True if the value is a standard iterable type. """ return isinstance(value, (list, tuple, set, dict, map, filter, range)) + + +def dict_shallow_equal(dict1: Mapping[str, Any], dict2: Mapping[str, Any]) -> bool: + """ + Check if two dictionaries are shallowly equal. By default Python does a deep equals check, but for props comparison we may just want a shallow equals. + + Args: + dict1: The first dict to compare. + dict2: The second dict to compare. + """ + if dict1.keys() != dict2.keys(): + return False + for key in dict1: + if dict1[key] is not dict2[key]: + return False + return True diff --git a/plugins/ui/src/deephaven/ui/components/component.py b/plugins/ui/src/deephaven/ui/components/component.py index 2c52b6777..6e027f88b 100644 --- a/plugins/ui/src/deephaven/ui/components/component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -1,25 +1,131 @@ from __future__ import annotations import functools import logging -from typing import Any, Callable -from .._internal import get_component_qualname -from ..elements import FunctionElement +from typing import Any, Callable, overload +from .._internal import get_component_qualname, dict_shallow_equal +from ..elements import Element, FunctionElement, MemoizedElement, PropsType logger = logging.getLogger(__name__) -def component(func: Callable[..., Any]): +# Type alias for comparison functions +CompareFunction = Callable[[PropsType, PropsType], bool] + + +def _default_are_props_equal(prev_props: PropsType, next_props: PropsType) -> bool: + """ + The default are_props_equal function that does a shallow comparison of the props. + + Args: + prev_props: The previous props to check against the current props. + next_props: The current props to check against the previous props. + + Returns: + True if the props are equal, False otherwise. + """ + # Children are passed in as positional args wrapped in a tuple, so the tuple + # itself is a different object on every render even when the children are the + # same. Compare children by value, then shallow compare the remaining props. + if "children" in prev_props or "children" in next_props: + if prev_props.get("children") != next_props.get("children"): + return False + + return dict_shallow_equal( + {k: v for k, v in prev_props.items() if k != "children"}, + {k: v for k, v in next_props.items() if k != "children"}, + ) + + +@overload +def component(func: Callable[..., Any]) -> Callable[..., Element]: + """Basic usage without parentheses: @ui.component""" + ... + + +@overload +def component( + *, + memo: bool | CompareFunction = ..., +) -> Callable[[Callable[..., Any]], Callable[..., Element]]: + """Usage with parameters: @ui.component() or @ui.component(memo=True)""" + ... + + +def component( + func: Callable[..., Any] | None = None, + *, + memo: bool | CompareFunction = False, +) -> Callable[..., Element] | Callable[[Callable[..., Any]], Callable[..., Element]]: """ Create a FunctionalElement from the passed in function. Args: func: The function to create a FunctionalElement from. Runs when the component is being rendered. + memo: Enable memoization to skip re-rendering when props are unchanged. + - False (default): No memoization, component always re-renders with parent. + - True: Enable memoization with shallow equality comparison. + - Callable: Custom comparison function (prev_props, next_props) -> bool + that returns True if props are equal (should skip re-render). + + Can be used in several ways: + + 1. Without parentheses (no memoization): + @ui.component + def my_component(value): + return ui.text(str(value)) + + 2. With parentheses (no memoization): + @ui.component() + def my_component(value): + return ui.text(str(value)) + + 3. With memo=True (shallow equality comparison): + @ui.component(memo=True) + def my_component(value): + return ui.text(str(value)) + + 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) """ + # Determine if memoization is enabled and what comparison function to use + if memo is False: + enable_memo = False + compare_fn: CompareFunction | None = None + elif memo is True: + enable_memo = True + compare_fn = _default_are_props_equal + elif callable(memo): + enable_memo = True + compare_fn = memo + else: + raise TypeError( + f"memo must be True, False, or a callable, got {type(memo).__name__}" + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Element]: + @functools.wraps(fn) + def make_component_node(*args: Any, key: str | None = None, **kwargs: Any): + component_type = get_component_qualname(fn) + element = FunctionElement( + component_type, lambda: fn(*args, **kwargs), key=key + ) + + if enable_memo and compare_fn is not None: + return MemoizedElement( + element, + {"children": args, **kwargs}, + compare_fn, + ) + return element - @functools.wraps(func) - def make_component_node(*args: Any, key: str | None = None, **kwargs: Any): - component_type = get_component_qualname(func) - return FunctionElement(component_type, lambda: func(*args, **kwargs), key=key) + return make_component_node - return make_component_node + if func is not None: + # Called without parentheses: @ui.component + return decorator(func) + else: + # Called with parentheses: @ui.component() or @ui.component(memo=True) + return decorator diff --git a/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py b/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py new file mode 100644 index 000000000..cc3d10aed --- /dev/null +++ b/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py @@ -0,0 +1,64 @@ +from __future__ import annotations +import logging +from typing import Callable, Optional + +from .Element import Element, PropsType +from .._internal import dict_shallow_equal, RenderContext + +logger = logging.getLogger(__name__) + + +class MemoizedElement(Element): + _element: Element + _props: PropsType + _are_props_equal: Callable[[PropsType, PropsType], bool] + + def __init__( + self, + element: Element, + props: PropsType, + are_props_equal: Callable[[PropsType, PropsType], bool], + ): + """ + Create an element that takes a function to render. + + Args: + element: The element to memoize. + props: The props of the element. + are_props_equal: A function that takes the previous props and the next props and returns whether they are equal. If the props are equal, the component will not re-render. If the props are not equal, the component will re-render. This is used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. + """ + self._element = element + self._props = props + self._are_props_equal = are_props_equal + + @property + def name(self): + return self._element.name + + @property + def key(self) -> str | None: + return self._element.key + + @property + def props(self) -> PropsType: + return self._props + + def are_props_equal(self, prev_props: PropsType) -> bool: + """ + Check if the props are equal using the are_props_equal function. + + Args: + prev_props: The previous props to check against the current props. + Returns: + True if the props are equal, False otherwise. + """ + return self._are_props_equal(prev_props, self._props) + + def render(self) -> PropsType: + """ + Render the component. Should only be called when actually rendering the component, e.g. exporting it to the client. + + Returns: + The props of this element. + """ + return self._element.render() diff --git a/plugins/ui/src/deephaven/ui/elements/__init__.py b/plugins/ui/src/deephaven/ui/elements/__init__.py index b81f58644..28b1230d2 100644 --- a/plugins/ui/src/deephaven/ui/elements/__init__.py +++ b/plugins/ui/src/deephaven/ui/elements/__init__.py @@ -3,6 +3,7 @@ from .ContextProviderElement import ContextProviderElement, Context, create_context from .DashboardElement import DashboardElement from .FunctionElement import FunctionElement +from .MemoizedElement import MemoizedElement from .UriElement import resolve __all__ = [ @@ -12,6 +13,7 @@ "DashboardElement", "Element", "FunctionElement", + "MemoizedElement", "NodeType", "PropsType", "resolve", diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py index d88d10f6f..2335e144e 100644 --- a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py +++ b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py @@ -436,6 +436,9 @@ def _set_url_state(self, url: str) -> None: """ logger.debug("Setting URL state: %s", url) self.set_url(url) + # The URL is not tracked as component state, so mark the root context dirty + # to ensure components that read the URL (e.g. use_path) are re-rendered. + self._context.mark_dirty() self._mark_dirty() def _serialize_callables(self, node: Any) -> Any: diff --git a/plugins/ui/src/deephaven/ui/renderer/Renderer.py b/plugins/ui/src/deephaven/ui/renderer/Renderer.py index afea0d013..d4740aea1 100644 --- a/plugins/ui/src/deephaven/ui/renderer/Renderer.py +++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py @@ -4,13 +4,18 @@ from typing import Any, Union from .._internal import RenderContext, remove_empty_keys -from ..elements import Element, PropsType +from ..elements import Element, MemoizedElement, PropsType from .RenderedNode import RenderedNode logger = logging.getLogger(__name__) -def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) -> Any: +def _render_child_item( + item: Any, + parent_context: RenderContext, + index_key: str, + is_dirty_render: bool, +) -> Any: """ Render a child item. If the item may have its own children, they will be rendered as well. @@ -18,24 +23,36 @@ def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) item: The item to render. parent_context: The context of the parent to render the item in. index_key: The key of the item in the parent context if it is a list or tuple. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree Returns: The rendered item. """ logger.debug("_render_child_item parent_context is %s", parent_context) + fetch_only = not is_dirty_render + if isinstance(item, (list, map, tuple)): - return _render_list(item, parent_context.get_child_context(index_key)) + return _render_list( + item, + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, + ) if isinstance(item, dict): - return _render_dict(item, parent_context.get_child_context(index_key)) + return _render_dict( + item, + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, + ) # If the item is an instance of a dataclass if is_dataclass(item) and not isinstance(item, type): shallow = {f.name: getattr(item, f.name) for f in fields(item)} return _render_dict( remove_empty_keys(shallow), - parent_context.get_child_context(index_key), + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, ) if isinstance(item, Element): @@ -45,14 +62,20 @@ def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) item, ) key = item.key or f"{index_key}-{item.name}" - return _render_element(item, parent_context.get_child_context(key)) + return _render_element( + item, + parent_context.get_child_context(key, fetch_only), + is_dirty_render, + ) logger.debug("render_item returning child (%s): %s", type(item), item) return item def _render_list( - item: Union[list[Any], map[Any], tuple[Any, ...]], context: RenderContext + item: Union[list[Any], map[Any], tuple[Any, ...]], + context: RenderContext, + is_dirty_render: bool, ) -> list[Any]: """ Render a list. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. @@ -61,19 +84,45 @@ def _render_list( Args: item: The list to render. context: The context to render the list in. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. Returns: The rendered list. """ logger.debug("_render_list %s", item) + if not is_dirty_render: + # Don't open the context + return _render_list_in_open_context(item, context, is_dirty_render) + with context.open(): - return [ - _render_child_item(value, context, str(key)) - for key, value in enumerate(item) - ] + return _render_list_in_open_context(item, context, is_dirty_render) + + +def _render_list_in_open_context( + item: Union[list[Any], map[Any], tuple[Any, ...]], + context: RenderContext, + is_dirty_render: bool, +) -> list[Any]: + """ + Render a list. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. + For example, a `label` prop of a button can accept a string or an element. + + Args: + item: The list to render. + context: The context to render the list in. This context should already be open. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. + Returns: + The rendered list. + """ + return [ + _render_child_item(value, context, str(key), is_dirty_render) + for key, value in enumerate(item) + ] -def _render_dict(item: PropsType, context: RenderContext) -> PropsType: +def _render_dict( + item: PropsType, context: RenderContext, is_dirty_render: bool +) -> PropsType: """ Render a dictionary. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. For example, a `label` prop of a button can accept a string or an element. @@ -81,17 +130,24 @@ def _render_dict(item: PropsType, context: RenderContext) -> PropsType: Args: item: The dictionary to render. context: The context to render the dictionary in. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. Returns: The rendered dictionary. """ logger.debug("_render_dict %s", item) + if not is_dirty_render: + # Don't open the context + return _render_dict_in_open_context(item, context, is_dirty_render) + with context.open(): - return _render_dict_in_open_context(item, context) + return _render_dict_in_open_context(item, context, is_dirty_render) -def _render_dict_in_open_context(item: PropsType, context: RenderContext) -> PropsType: +def _render_dict_in_open_context( + item: PropsType, context: RenderContext, is_dirty_render: bool +) -> PropsType: """ Render a dictionary. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. For example, a `label` prop of a button can accept a string or an element. @@ -99,33 +155,77 @@ def _render_dict_in_open_context(item: PropsType, context: RenderContext) -> Pro Args: item: The dictionary to render. context: The context to render the dictionary in. + is_dirty_render: Whether we are re-rendering an existing element. This is used to determine whether to use the existing child context or create a new one when rendering child elements. Returns: The rendered dictionary. """ - return {key: _render_child_item(value, context, key) for key, value in item.items()} + return { + key: _render_child_item(value, context, key, is_dirty_render) + for key, value in item.items() + } -def _render_element(element: Element, context: RenderContext) -> RenderedNode: +def _render_element( + element: Element, context: RenderContext, is_dirty_render: bool +) -> RenderedNode: """ Render an Element. Args: element: The element to render. context: The context to render the component in. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. Returns: The RenderedNode representing the element. """ - logger.debug("Rendering element %s in context %s", element.name, context) + logger.debug( + "Rendering element %s (%s) in context %s, cache: %s", + element.name, + type(element), + context, + context.cache, + ) + + # Props that are being passed into this Element + element_props = None + + # Props that are returned after calling the elements render() method. These will be cached + rendered_element_props = None + + if isinstance(element, MemoizedElement): + element_props = element.props + + if context.cache is not None: + # First check if we can use the result from the cache + prev_props, prev_rendered_element_props = context.cache + + needs_render = is_dirty_render + + if isinstance(element, MemoizedElement): + needs_render = not element.are_props_equal(prev_props) + + 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) with context.open(): - props = element.render() + logger.debug("Rendering element %s", element.name) + + rendered_element_props = element.render() + + context.cache = (element_props, rendered_element_props) # We also need to render any elements that are passed in as props (including `children`) - props = _render_dict_in_open_context(props, context) + rendered_props = _render_dict_in_open_context( + rendered_element_props, context, True + ) - return RenderedNode(element.name, props) + return RenderedNode(element.name, rendered_props) class Renderer: @@ -133,6 +233,16 @@ class Renderer: Renders Elements provided into the RenderContext provided and returns a RenderedNode. At this step it executing the render() method of the Element within the RenderContext state to generate the realized Document tree for the Element provided. + + There are a few things to note about the Renderer and RenderContext: + - The Renderer is responsible for rendering an Element and all of its children, but it does not manage the state of the Element, or liveness scopes, or hooks. Those are all tracked by the RenderContext. + - There is a RenderContext created for each Element. If that Element unmounts, that RenderContext will be destroyed. + - The RenderContext also has a cache that the Renderer uses to store the previous rendered result of an Element. + - State changes in an Element will mark the RenderContext as dirty, which will cause the Renderer to re-render the Element and its children. + - When an Element is dirty, it will be re-rendered along with all of it's children + - When a MemoizedElement is encountered, the Renderer will _only_ re-render if the props have changes (as determined by the MemoizedElement's are_props_equal function) or if the context of that Element is dirty, whether it's a dirty render or not (e.g. one of it's parent components was marked dirty) + + By following these rules, we can ensure that we are only re-rendering the parts of the tree that need to be re-rendered, and we can skip re-rendering for parts of the tree that have not changed, even in cases where their parent Element has changed. """ _context: RenderContext @@ -153,4 +263,4 @@ def render(self, element: Element) -> RenderedNode: Returns: The rendered element. """ - return _render_element(element, self._context) + return _render_element(element, self._context, False) diff --git a/plugins/ui/test/deephaven/ui/test_memo.py b/plugins/ui/test/deephaven/ui/test_memo.py new file mode 100644 index 000000000..b913ccdc1 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -0,0 +1,1288 @@ +""" +Tests for component memoization (memo parameter on @ui.component). + +The memo parameter on @ui.component allows components to skip re-rendering when their props haven't +changed, similar to React.memo(). +""" + +from __future__ import annotations +from unittest.mock import Mock +from typing import Any, Callable, List, Union +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui.renderer.RenderedNode import RenderedNode +from deephaven.ui._internal.RenderContext import ( + RenderContext as _RenderContext, + OnChangeCallable, +) +from deephaven import ui +from .BaseTest import BaseTestCase +from .test_utils_root import TestRoot + +run_on_change: OnChangeCallable = lambda x: x() + + +def RenderContext( + on_change: OnChangeCallable, on_queue: OnChangeCallable +) -> _RenderContext: + """Create a RenderContext for tests by wrapping the callbacks in a TestRoot. + + The RenderContext constructor takes a single root protocol, so this shim + preserves the ``RenderContext(on_change, on_queue)`` call style used below. + """ + return _RenderContext(TestRoot(on_change, on_queue)) + + +class MemoTestCase(BaseTestCase): + """Tests for component memoization (memo parameter on @ui.component).""" + + def _find_node(self, root: RenderedNode, name: str) -> RenderedNode: + """Helper to find a node by name in the rendered tree.""" + if root.name == name: + return root + children: Union[Any, List[Any]] = ( + root.props.get("children", []) if root.props is not None else [] + ) + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") + + def _find_action_button(self, root: RenderedNode) -> RenderedNode: + return self._find_node(root, "deephaven.ui.components.ActionButton") + + def test_memo_skips_rerender_with_same_props(self): + """Test that memo=True skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + # Pass same value to child regardless of parent state + return ui.flex( + ui.action_button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (change parent state) + button = self._find_action_button(result) + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_skips_rerender_with_same_positional_props(self): + """Test that memo=True skips re-render when positional (children) props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(name: str): + child_render_count[0] += 1 + return ui.text(f"Hello, {name}!") + + @ui.component + def parent(): + parent_state, set_parent_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + # Pass the same value positionally each render + memoized_child("World"), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (change parent state) + button = self._find_action_button(result) + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_rerenders_when_props_change(self): + """Test that memo=True re-renders when props change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Increment: {value}", + on_press=lambda _: set_value(value + 1), + ), + memoized_child(value=value), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change the prop value by clicking the button + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) + + def test_memo_rerenders_when_own_state_changes(self): + """Test that memo=True re-renders when the memoized component's own state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + internal_state, set_internal_state = ui.use_state(0) + return ui.action_button( + f"Value: {value}, Internal: {internal_state}", + on_press=lambda _: set_internal_state(internal_state + 1), + ) + + @ui.component + def parent(): + # Always pass the same props + return memoized_child(value=42) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change state within memoized component + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + # Child should re-render because its own state changed (context is dirty) + self.assertEqual(child_render_count[0], 2) + + def test_memo_rerenders_when_both_props_and_state_change(self): + """Test that memo=True re-renders when both props and internal state change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + button_ref = [None] + parent_setter_ref = [None] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + internal_state, set_internal_state = ui.use_state(0) + btn = ui.action_button( + f"Value: {value}, Internal: {internal_state}", + on_press=lambda _: set_internal_state(internal_state + 1), + ) + button_ref[0] = btn + return btn + + @ui.component + def parent(): + prop_value, set_prop_value = ui.use_state(0) + parent_setter_ref[0] = set_prop_value + return memoized_child(value=prop_value) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change both props (via parent) and internal state + button = self._find_action_button(result) + button.props["onPress"](None) # Change internal state + parent_setter_ref[0](1) # Change props + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered due to both changes + + def test_memo_no_rerender_when_nothing_changes(self): + """Test that memo=True doesn't re-render when nothing changes (forced parent re-render).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(): + child_render_count[0] += 1 + return ui.text("Static content") + + @ui.component + def parent(): + parent_render_count[0] += 1 + counter, set_counter = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Count: {counter}", + on_press=lambda _: set_counter(counter + 1), + ), + memoized_child(), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Force several parent re-renders + for i in range(3): + button = self._find_action_button(result) + button.props["onPress"](None) + result = renderer.render(parent()) + + self.assertEqual(parent_render_count[0], 4) # Parent re-rendered 4 times total + self.assertEqual(child_render_count[0], 1) # Child NEVER re-rendered + + def test_memo_with_custom_compare(self): + """Test that custom compare function controls memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev.get("value") == next.get("value") + + @ui.component(memo=compare_only_value) + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.action_button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return ui.flex( + ui.action_button( + f"Parent count: {count}", + on_press=lambda _: set_count(count + 1), + ), + child_with_callback(value=42, on_click=callback), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback, but custom compare ignores it) + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + # Child SKIPPED because custom compare only checks 'value' which is still 42 + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_deep_equality(self): + """Test custom compare with deep equality for object props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that does deep equality on lists + def deep_equal_items(prev, next): + prev_items = prev.get("children", [[]])[0] # positional arg + next_items = next.get("children", [[]])[0] + return prev_items == next_items # List equality compares contents + + @ui.component(memo=deep_equal_items) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(items)) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Creates new list object each render, but with same contents + items = [1, 2, 3] + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render - new list object but same contents + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # SKIPPED because custom compare does deep equality + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_always_rerender(self): + """Test custom compare that always returns False (always re-renders).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that always returns False - props are never "equal" + def always_different(prev, next): + return False + + @ui.component(memo=always_different) + def always_rerender_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + always_rerender_child(value=42), # Same props every time + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Re-rendered because custom compare returns False + self.assertEqual(child_render_count[0], 2) + + def test_memo_custom_compare_always_skip(self): + """Test custom compare that always returns True (never re-renders).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that always returns True - props are always "equal" + def always_equal(prev, next): + return True + + @ui.component(memo=always_equal) + def never_rerender_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + never_rerender_child(value=state), # Props actually change! + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render - props change but custom compare says they're equal + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # SKIPPED even though props changed, because custom compare returns True + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_selective_props(self): + """Test custom compare that checks only specific props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Only re-render if 'important_value' changes, ignore 'metadata' and 'callback' + def compare_important_only(prev, next): + return prev.get("important_value") == next.get("important_value") + + @ui.component(memo=compare_important_only) + def selective_child(important_value: int, metadata: dict, callback): + child_render_count[0] += 1 + return ui.action_button(f"Important: {important_value}", on_press=callback) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # metadata changes each render, but important_value stays the same + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + selective_child( + important_value=42, + metadata={"render_count": state}, # Changes each time + callback=lambda _: None, # New function each time + ), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger multiple re-renders - metadata and callback change, important_value doesn't + for _ in range(3): + button = self._find_action_button(result) + button.props["onPress"](None) + result = renderer.render(parent()) + + # Child never re-rendered because important_value stayed at 42 + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_with_threshold(self): + """Test custom compare that only re-renders on significant changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + parent_set_value = [None] + + # Only re-render if value changes by more than 5 + def significant_change_only(prev, next): + prev_val = prev.get("children", [[0]])[0] # positional arg + next_val = next.get("children", [[0]])[0] + return abs(next_val - prev_val) <= 5 + + @ui.component(memo=significant_change_only) + def threshold_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + parent_set_value[0] = set_value + return ui.flex( + threshold_child(value), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Small change (within threshold) - should skip + parent_set_value[0](3) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Another small change - should skip + parent_set_value[0](5) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Big change (exceeds threshold) - should re-render + parent_set_value[0](15) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) + + def test_memo_with_object_props_same_reference(self): + """Test memoization behavior with object props (same reference).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + # Same list object each time (defined outside component) + shared_list = [1, 2, 3] + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=shared_list), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (same list reference) + + def test_memo_with_object_props_new_reference(self): + """Test that memoization re-renders with new object references.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Creates new list object each render + items = [1, 2, 3] + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (new list reference) + + def test_memo_nested_components(self): + """Test memoization with nested components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_count[0] += 1 + return ui.text(f"Child: {value}") + + @ui.component(memo=True) + def memoized_parent(value: int): + parent_count[0] += 1 + return ui.flex( + ui.text(f"Parent: {value}"), + memoized_child(value=value), + ) + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + self.assertEqual(child_count[0], 1) + + # Trigger grandparent re-render with same props to children + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 2) # Grandparent re-rendered + self.assertEqual(parent_count[0], 1) # Parent SKIPPED (props unchanged) + self.assertEqual(child_count[0], 1) # Child SKIPPED (parent didn't re-render) + + def test_memo_nested_with_internal_state(self): + """Test memoization with nested components where inner has state.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_state_setter = [None] + + @ui.component(memo=True) + def memoized_parent(value: int): + parent_count[0] += 1 + child_state, set_child_state = ui.use_state("initial") + child_state_setter[0] = set_child_state + return ui.text(f"{value}: {child_state}") + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + + # Change state within memoized component (dirty tracking should work) + child_state_setter[0]("updated") + result = renderer.render(grandparent()) + # grandparent component function should not re-run because it's own state didn't change + self.assertEqual( + grandparent_count[0], 1 + ) # Grandparent re-rendered (root element) + # parent_count should be 2 because its own context is dirty (state changed) + self.assertEqual(parent_count[0], 2) # Parent re-rendered (own state dirty) + + # Now trigger grandparent re-render with same props to memoized_parent + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual( + grandparent_count[0], 2 + ) # Grandparent re-rendered (state changed) + self.assertEqual( + parent_count[0], 2 + ) # Parent SKIPPED (props unchanged, not dirty) + + def test_memo_with_multiple_props(self): + """Test memoization with multiple props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(a: int, b: str, c: bool): + child_render_count[0] += 1 + return ui.text(f"{a}-{b}-{c}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(a=1, b="hello", c=True), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render with same props + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (all props same) + + def test_memo_with_one_prop_changed(self): + """Test memoization re-renders when one of multiple props changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(a: int, b: str, c: bool): + child_render_count[0] += 1 + return ui.text(f"{a}-{b}-{c}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Only 'a' changes with state + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(a=state, b="hello", c=True), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render - prop 'a' changes + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (prop 'a' changed) + + def test_memo_with_children_prop(self): + """Test memoization with children passed as positional args.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + wrapper_render_count = [0] + + @ui.component(memo=True) + def memoized_wrapper(child_element): + wrapper_render_count[0] += 1 + return ui.view(child_element) + + # Create a stable child element + stable_child = ui.text("Static child") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_wrapper(stable_child), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(wrapper_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Should skip because the same stable_child object is passed + self.assertEqual(wrapper_render_count[0], 1) + + def test_memo_with_none_props(self): + """Test memoization handles None props correctly.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(value=None), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render with same None prop + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (None == None) + + def test_non_memoized_always_rerenders(self): + """Test that non-memoized components always re-render with parent.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component + def non_memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + non_memoized_child(value=42), # Same props each time + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) + # Non-memoized child should re-render even with same props + self.assertEqual(child_render_count[0], 2) + + def test_memo_component_with_parentheses_no_args(self): + """Test that @ui.component() (with empty parens) still works without memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component() + def non_memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + non_memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Should re-render because component is not memoized + self.assertEqual(child_render_count[0], 2) + + def test_memo_child_with_internal_state(self): + """Test that a memoized component's child with internal state renders correctly when state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + memoized_render_count = [0] + stateful_child_render_count = [0] + + @ui.component + def stateful_child(): + """A non-memoized child component with internal state.""" + stateful_child_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"Child count: {count}", + on_press=lambda _: set_count(count + 1), + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + """A memoized parent that renders a stateful child.""" + memoized_render_count[0] += 1 + return ui.flex( + ui.text(f"Prop: {prop_value}"), + stateful_child(), + ) + + @ui.component + def root(): + """Root component that renders the memoized parent with same props.""" + return memoized_parent(prop_value=42) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(root()) + self.assertEqual(memoized_render_count[0], 1) + self.assertEqual(stateful_child_render_count[0], 1) + + # Find the child's button and click it to change internal state + button = self._find_action_button(result) + self.assertEqual(button.props["children"], "Child count: 0") + + # Click the button to update child's internal state + button.props["onPress"](None) + + # Re-render + result = renderer.render(root()) + + # The memoized parent should NOT re-render (props unchanged) + # But the stateful child SHOULD re-render (its state changed) + self.assertEqual(memoized_render_count[0], 1) # Memoized parent skipped + self.assertEqual(stateful_child_render_count[0], 2) # Child re-rendered + + # Verify the child's state was actually updated in the rendered output + button = self._find_action_button(result) + self.assertEqual(button.props["children"], "Child count: 1") + + def _find_action_buttons(self, root: RenderedNode) -> list[RenderedNode]: + """Helper to find all action buttons in the rendered tree.""" + buttons = [] + if root.name == "deephaven.ui.components.ActionButton": + buttons.append(root) + children = root.props.get("children", []) if root.props is not None else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + buttons.extend(self._find_action_buttons(child)) + return buttons + + def test_selective_rerender_scenario1_grandparent_state_no_prop_change(self): + """ + Scenario 1: Grandparent state changes but does NOT affect MemoizedParent's props. + + Expected: + - Grandparent: re-renders (state changed) + - MemoizedParent: skipped (props unchanged) + - ChildA: skipped (parent skipped, own state unchanged) + - UnmemoizedParent: re-renders (not memoized) + - ChildB: re-renders (parent re-rendered) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=42), # Always same prop + unmemoized_parent(prop_value=gp_state), # Prop changes with state + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find grandparent's button and click it + buttons = self._find_action_buttons(result) + gp_button = next(b for b in buttons if "Grandparent:" in b.props["children"]) + gp_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # Grandparent re-rendered (state changed) + self.assertEqual(grandparent_render_count[0], 2) + # MemoizedParent skipped (props unchanged: prop_value=42) + self.assertEqual(memoized_parent_render_count[0], 1) + # ChildA skipped (parent skipped, own state unchanged) + self.assertEqual(child_a_render_count[0], 1) + # UnmemoizedParent re-rendered (not memoized, parent re-rendered) + self.assertEqual(unmemoized_parent_render_count[0], 2) + # ChildB re-rendered (parent re-rendered) + self.assertEqual(child_b_render_count[0], 2) + + def test_selective_rerender_scenario2_grandparent_state_with_prop_change(self): + """ + Scenario 2: Grandparent state changes AND affects MemoizedParent's props. + + Expected: + - Grandparent: re-renders (state changed) + - MemoizedParent: re-renders (props changed) + - ChildA: re-renders (parent re-rendered) + - UnmemoizedParent: re-renders (not memoized) + - ChildB: re-renders (parent re-rendered) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=gp_state), # Prop changes with state + unmemoized_parent(prop_value=gp_state), # Prop changes with state + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find grandparent's button and click it + buttons = self._find_action_buttons(result) + gp_button = next(b for b in buttons if "Grandparent:" in b.props["children"]) + gp_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # All components should re-render + self.assertEqual(grandparent_render_count[0], 2) + self.assertEqual(memoized_parent_render_count[0], 2) # Props changed + self.assertEqual(child_a_render_count[0], 2) + self.assertEqual(unmemoized_parent_render_count[0], 2) + self.assertEqual(child_b_render_count[0], 2) + + def test_selective_rerender_scenario3_child_state_change_only(self): + """ + Scenario 3: Child state changes (within memoized parent). + + Expected: + - Grandparent: NOT re-rendered (state unchanged) + - MemoizedParent: NOT re-rendered (props unchanged) + - ChildA: re-renders (its own state changed) + - UnmemoizedParent: NOT re-rendered (parent unchanged) + - ChildB: NOT re-rendered (state unchanged) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=42), # Always same prop + unmemoized_parent(prop_value=42), # Always same prop + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find ChildA's button and click it to change its internal state + buttons = self._find_action_buttons(result) + child_a_button = next(b for b in buttons if "ChildA:" in b.props["children"]) + self.assertEqual(child_a_button.props["children"], "ChildA: 0") + child_a_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # Grandparent should NOT re-render (state unchanged) + self.assertEqual(grandparent_render_count[0], 1) + # MemoizedParent should NOT re-render (props unchanged) + self.assertEqual(memoized_parent_render_count[0], 1) + # ChildA SHOULD re-render (its state changed) + self.assertEqual(child_a_render_count[0], 2) + # UnmemoizedParent should NOT re-render + self.assertEqual(unmemoized_parent_render_count[0], 1) + # ChildB should NOT re-render + self.assertEqual(child_b_render_count[0], 1) + + # Verify ChildA's state was actually updated in the rendered output + buttons = self._find_action_buttons(result) + child_a_button = next(b for b in buttons if "ChildA:" in b.props["children"]) + self.assertEqual(child_a_button.props["children"], "ChildA: 1") + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/plugins/ui/test/deephaven/ui/test_renderer.py b/plugins/ui/test/deephaven/ui/test_renderer.py index ca22f9907..3b65c5f5d 100644 --- a/plugins/ui/test/deephaven/ui/test_renderer.py +++ b/plugins/ui/test/deephaven/ui/test_renderer.py @@ -150,7 +150,7 @@ def ui_parent(): assert count_btn.props != None self.assertEqual(count_btn.props["children"], "Count is 1") - # Only the counter with deps effect and no deps effects should have been called + # Only the counter effects should run - parent doesn't re-render since only counter's state changed self.assertEqual( called_funcs, [ @@ -158,8 +158,6 @@ def ui_parent(): "counter_with_deps_cleanup", "counter_no_deps_effect", "counter_with_deps_effect", - "parent_no_deps_cleanup", - "parent_no_deps_effect", ], ) called_funcs.clear() @@ -236,12 +234,12 @@ def test_render_child_item(self): rc = RenderContext(_TestRoot(Mock(), Mock())) self.assertEqual( - _render_child_item({"key": "value"}, rc, "key"), + _render_child_item({"key": "value"}, rc, "key", True), {"key": "value"}, ) self.assertEqual( - _render_child_item([0, 1, 2], rc, "key"), + _render_child_item([0, 1, 2], rc, "key", True), [0, 1, 2], ) @@ -255,7 +253,7 @@ class MyDataclass: b: Element nested_dataclass = _render_child_item( - [MyDataclass("test", my_comp())], rc, "key" + [MyDataclass("test", my_comp())], rc, "key", True )[0] self.assertEqual( diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index d8300c361..a139035e4 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -2,10 +2,12 @@ from unittest.mock import Mock from typing import Any, Callable, Dict, List +from deephaven.ui._internal import RootRenderContextProtocol +from deephaven.ui.types import QueryParams from .BaseTest import BaseTestCase -class _TestRoot: +class _TestRoot(RootRenderContextProtocol): """Minimal RootRenderContextProtocol implementation for tests.""" def __init__(self, on_change_fn, on_queue_fn): @@ -25,6 +27,14 @@ def get_url(self) -> str: def set_url(self, url: str) -> None: self._url = url + def get_query_params(self) -> QueryParams: + """Get the current URL query parameters.""" + return dict() + + def set_query_params(self, query_params: QueryParams) -> None: + """Update the URL query parameters.""" + pass + class UITableTestCase(BaseTestCase): def setUp(self) -> None: diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py index 31697a2da..421abe10c 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -6,6 +6,7 @@ from deephaven.ui._internal.utils import ( convert_dict_keys, create_props, + dict_shallow_equal, dict_to_camel_case, dict_to_react_props, get_component_name, @@ -471,6 +472,52 @@ def __iter__(self): self.assertFalse(is_iterable(CustomIterable())) + def test_dict_shallow_equal(self): + # Two empty dicts are equal + self.assertTrue(dict_shallow_equal({}, {})) + + # Same keys with identical values (same object) should be equal + obj1 = {"nested": "value"} + obj2 = [1, 2, 3] + dict1 = {"a": obj1, "b": obj2} + dict2 = {"a": obj1, "b": obj2} + self.assertTrue(dict_shallow_equal(dict1, dict2)) + + # Same keys with equal but not identical values should NOT be equal + dict3 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + dict4 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + self.assertFalse(dict_shallow_equal(dict3, dict4)) + + # Different keys should not be equal + self.assertFalse(dict_shallow_equal({"a": 1}, {"b": 1})) + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 1, "b": 2})) + self.assertFalse(dict_shallow_equal({"a": 1, "b": 2}, {"a": 1})) + + # Primitives: small ints and interned strings have the same identity + self.assertTrue( + dict_shallow_equal({"a": 1, "b": "hello"}, {"a": 1, "b": "hello"}) + ) + self.assertTrue(dict_shallow_equal({"x": None}, {"x": None})) + self.assertTrue( + dict_shallow_equal({"x": True, "y": False}, {"x": True, "y": False}) + ) + + # Different primitive values + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 2})) + self.assertFalse(dict_shallow_equal({"a": "foo"}, {"a": "bar"})) + + # Test with callables - same function object + def my_func(): + pass + + self.assertTrue(dict_shallow_equal({"func": my_func}, {"func": my_func})) + + # Different function objects (even with same behavior) should NOT be equal + def my_func2(): + pass + + self.assertFalse(dict_shallow_equal({"func": my_func}, {"func": my_func2})) + if __name__ == "__main__": unittest.main() diff --git a/tests/app.d/tests.app b/tests/app.d/tests.app index 10beb52cc..44deab6cc 100644 --- a/tests/app.d/tests.app +++ b/tests/app.d/tests.app @@ -21,3 +21,4 @@ file_14=ui_query_params.py file_15=ui_home_screen.py file_16=ui_routing.py file_17=ui_events.py +file_18=ui_memo.py diff --git a/tests/app.d/ui_memo.py b/tests/app.d/ui_memo.py new file mode 100644 index 000000000..308f123f6 --- /dev/null +++ b/tests/app.d/ui_memo.py @@ -0,0 +1,43 @@ +from deephaven import ui +import random + + +@ui.component(memo=True) +def memo_greeting(name: str): + """Memoized component that takes an input (``name``) and renders a child. + + Because it is memoized, it only re-renders when ``name`` changes. This lets + us verify that the child renders correctly after an input change, but is + skipped when unrelated parent state (the count) updates. + """ + return ui.text(f"Hello, {name}!", UNSAFE_class_name="memo-greeting") + + +@ui.component(memo=True) +def memo_random_value(label: str): + """Memoized component that renders a random value. + + The random value is generated during render. Because the component is + memoized and its ``label`` prop never changes, it should not re-render when + the parent updates, so the value stays the same across parent re-renders. + """ + value = random.randint(0, 1_000_000_000) + return ui.text(f"Random: {value}", UNSAFE_class_name="memo-random") + + +@ui.component +def ui_memo_example_component(): + count, set_count = ui.use_state(0) + value, set_value = ui.use_state("World") + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}", UNSAFE_class_name="memo-count"), + ui.text_field(default_value=value, on_change=set_value, label="Input value"), + memo_greeting(value), # Won't re-render when count changes + memo_random_value("constant"), # Random value stays the same on re-render + direction="column", + ) + + +ui_memo_example = ui_memo_example_component() diff --git a/tests/ui_memo.spec.ts b/tests/ui_memo.spec.ts new file mode 100644 index 000000000..20f4180f8 --- /dev/null +++ b/tests/ui_memo.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { gotoPage, openPanel, SELECTORS } from './utils'; + +// The `ui_memo_example` panel exercises `@ui.component(memo=True)`. +// +// It renders: +// - An "Increment" button and a "Count" text driven by parent state. +// - A "Input value" text field whose value is passed to `memo_greeting`. +// - `memo_greeting(value)` - a memoized child that renders "Hello, {value}!". +// It should re-render only when `value` changes, not when `count` changes. +// - `memo_random_value("constant")` - a memoized child that renders a random +// value. Its prop never changes, so it should never re-render and the +// random value should stay the same across parent re-renders. +test('ui.component(memo=True) skips re-render when props are unchanged and re-renders when they change', async ({ + page, +}) => { + await gotoPage(page, ''); + await openPanel( + page, + 'ui_memo_example', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + + const panelLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); + + const incrementButton = panelLocator.getByRole('button', { + name: 'Increment', + }); + const count = panelLocator.locator('.memo-count'); + const greeting = panelLocator.locator('.memo-greeting'); + const randomValue = panelLocator.locator('.memo-random'); + const input = panelLocator.getByRole('textbox', { name: 'Input value' }); + + // Initial render. + await expect(count).toHaveText('Count: 0'); + await expect(greeting).toHaveText('Hello, World!'); + await expect(input).toHaveValue('World'); + + // Capture the initial random value rendered by the memoized component. + const initialRandom = await randomValue.textContent(); + expect(initialRandom).toMatch(/^Random: \d+$/); + + // Incrementing the count re-renders the parent, but the memoized children's + // props are unchanged, so neither should re-render. + await incrementButton.click(); + await expect(count).toHaveText('Count: 1'); + await incrementButton.click(); + await expect(count).toHaveText('Count: 2'); + + // The greeting prop (value) did not change, so it stays the same. + await expect(greeting).toHaveText('Hello, World!'); + // The random value component's prop never changes, so the value is stable. + await expect(randomValue).toHaveText(initialRandom ?? ''); + + // Changing the input changes the prop passed to `memo_greeting`, so the + // memoized child re-renders and reflects the new value. + await input.click(); + await input.press('ControlOrMeta+a'); + await input.pressSequentially('Deephaven', { delay: 0 }); + await input.blur(); + await expect(greeting).toHaveText('Hello, Deephaven!'); + + // The random value component's prop ("constant") still did not change, so it + // should remain memoized and keep its original value. + await expect(randomValue).toHaveText(initialRandom ?? ''); +});