diff --git a/.gitignore b/.gitignore index b9bbaaf57..fcdc29ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ junit.xml # Allow for local overrides of docker-compose.yml. https://docs.docker.com/compose/multiple-compose-files/merge/ docker-compose.override.yml +docker-compose.docs-snapshots.override.yml # Ignore temporary files created during a release releases/ 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..3e1fc6f2f --- /dev/null +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -0,0 +1,301 @@ +# 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 + +When a parent component re-renders, its children are considered for re-rendering 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. + +Memoization only applies to props from the parent. A memoized component will still re-render when its own state changes. + +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` + +Treat `memo` as a performance optimization, not as a requirement for correctness. Most components do not need it. + +Use `memo=True` when: + +- A component re-renders often with the same props +- A component is expensive to render because it builds a large UI subtree or many children +- A parent component re-renders frequently but passes stable props to the child +- You have measured or observed lag from unnecessary re-renders + +Don't use `memo` when: + +- The component's props change on almost every render +- The component is cheap to render +- The expensive part is a calculation inside the component. Use [`use_memo`](../hooks/use_memo.md) for that instead. +- The underlying issue is an impure render or side effect during rendering +- You're prematurely optimizing without measuring performance + +```python +from deephaven import ui + + +# Good candidate: large rendered subtree with stable props +@ui.component(memo=True) +def activity_feed(entries): + return ui.flex( + *[ + ui.flex( + ui.text(entry["time"]), + ui.text(entry["message"]), + gap="size-100", + ) + for entry in entries + ], + direction="column", + gap="size-100", + ) + + +# Not a good candidate: prop changes 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) + entries = ui.use_memo( + lambda: [ + {"time": "09:00", "message": "Connected"}, + {"time": "09:02", "message": "Loaded table"}, + {"time": "09:05", "message": "Opened dashboard"}, + ], + [], + ) + + return ui.flex( + ui.button("Update", on_press=lambda: set_count(count + 1)), + live_counter(count), # No benefit from memo - count always changes + activity_feed(entries), # Stable props let memo skip rebuilding the feed + direction="column", + ) + + +dashboard_example = dashboard() +``` + +In this example, `use_memo` keeps `entries` stable, while `memo=True` avoids rebuilding the `activity_feed` subtree each time `dashboard` updates for unrelated reasons. + +## 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 a custom comparison function +@ui.component(memo=my_custom_compare) +def my_component_custom(prop): + return ui.text(prop) +``` + +## 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`: + +> [!WARNING] +> Custom comparison functions are rare. Prefer reducing prop changes first by passing simpler props or stabilizing objects and callbacks with [`use_memo`](../hooks/use_memo.md) and `use_callback`. If you do write a custom comparator, compare every prop that affects rendering or behavior. + +```python +from deephaven import ui + + +def compare_series(prev_props, next_props): + """Compare a bounded series shape prop-by-prop.""" + prev_points = prev_props.get("data_points", ()) + next_points = next_props.get("data_points", ()) + + return ( + prev_props.get("color") == next_props.get("color") + and len(prev_points) == len(next_points) + and all( + prev_point["x"] == next_point["x"] and prev_point["y"] == next_point["y"] + for prev_point, next_point in zip(prev_points, next_points) + ) + ) + + +@ui.component(memo=compare_series) +def sparkline(data_points, color="blue"): + return ui.flex( + ui.text(f"Color: {color}"), + *[ui.text("({}, {})".format(point["x"], point["y"])) for point in data_points], + direction="column", + ) + + +@ui.component +def chart_panel(): + tick, set_tick = ui.use_state(0) + points = ui.use_memo( + lambda: [ + {"x": 0, "y": 2}, + {"x": 1, "y": 5}, + {"x": 2, "y": 3}, + ], + [], + ) + + return ui.flex( + ui.button("Tick", on_press=lambda: set_tick(tick + 1)), + ui.text(f"Tick: {tick}"), + sparkline(data_points=points, color="blue"), + direction="column", + ) + + +chart_panel_example = chart_panel() +``` + +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. + +When writing a custom comparison function: + +- Compare every prop that affects output or behavior, including callback props +- Only use custom comparison for data with a known, limited shape +- Measure whether the comparison is actually cheaper than re-rendering +- Avoid generic deep equality checks on unknown or deeply nested structures + +## 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) + + # Bad: creating the list inline changes the reference every render. + # item_list(["apple", "banana"]) + + # Good: use use_memo to keep the same reference. + items_good = ui.use_memo(lambda: ["apple", "banana"], []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + item_list(items_good), # Skips unnecessary re-renders. + direction="column", + ) + + +app_example = app() +``` + +### Passing Callback Functions + +Lambda functions and inline function definitions create new references each render: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def button_row(on_click): + return ui.button("Click me", on_press=on_click) + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + # Bad: creating the callback inline changes the reference every render. + # button_row(on_click=lambda: None) + + # Good: 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), # Skips unnecessary re-renders. + direction="column", + ) + + +app_example = app() +``` + +### Side Effects During Rendering + +Memoized components still need [pure rendering logic](../describing/pure_components.md). If a component mutates global state, performs I/O, or depends on side effects during rendering, `memo` can hide the bug by causing that render to run less often. + +Keep rendering pure, and move side effects into event handlers or [`use_effect`](../hooks/use_effect.md). + +## 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..5da48096a 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 a component's state changes, `deephaven.ui` only re-renders that component and its descendants. However, if you have expensive components or want to avoid unnecessary re-renders of children when their props haven't changed, 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/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/3c9adc100f45b160fb6e1a08a0a2cf3f.json b/plugins/ui/docs/snapshots/3c9adc100f45b160fb6e1a08a0a2cf3f.json new file mode 100644 index 000000000..21b6f0e74 --- /dev/null +++ b/plugins/ui/docs/snapshots/3c9adc100f45b160fb6e1a08a0a2cf3f.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/6afba8daf24322c5db66a143ab91ab16.json b/plugins/ui/docs/snapshots/6afba8daf24322c5db66a143ab91ab16.json new file mode 100644 index 000000000..ae6fc06f9 --- /dev/null +++ b/plugins/ui/docs/snapshots/6afba8daf24322c5db66a143ab91ab16.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"chart_panel_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":"Tick"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Tick: 0"],"slot":"text"}},{"__dhElemName":"__main__.sparkline","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Color: blue"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["(0, 2)"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["(1, 5)"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["(2, 3)"],"slot":"text"}}]}}}}]}}},"__dhElemName":"__main__.chart_panel"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/95b3616bf94d741c9d69b75553568ee6.json b/plugins/ui/docs/snapshots/95b3616bf94d741c9d69b75553568ee6.json new file mode 100644 index 000000000..3ecffb814 --- /dev/null +++ b/plugins/ui/docs/snapshots/95b3616bf94d741c9d69b75553568ee6.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/9dd513fd4020974cabbc89b96a99e6e2.json b/plugins/ui/docs/snapshots/9dd513fd4020974cabbc89b96a99e6e2.json new file mode 100644 index 000000000..f2ea2f2c2 --- /dev/null +++ b/plugins/ui/docs/snapshots/9dd513fd4020974cabbc89b96a99e6e2.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__.activity_feed","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["09:00"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Connected"],"slot":"text"}}]}},{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["09:02"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Loaded table"],"slot":"text"}}]}},{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["09:05"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Opened dashboard"],"slot":"text"}}]}}]}}}}]}}},"__dhElemName":"__main__.dashboard"},"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/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index e67160444..f9dd4952a 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.mark_clean() + # 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,33 @@ 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 mark_clean(self) -> None: + """ + Mark this context as clean. + Called after a successful render pass to reset the dirty flag. Any state + changes that occur after this (including inside effects) will mark the + context dirty again for the next render. + """ + self._is_dirty = False + def has_state(self, key: StateKey) -> bool: """ Check if the given key is in the state. @@ -493,12 +535,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.mark_dirty() - 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 +676,7 @@ def import_state(self, state: dict[str, Any]) -> None: """ self._state.clear() self._children_context.clear() + self.mark_dirty() if "state" in state: for key, value in state["state"].items(): @@ -652,3 +712,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..498622095 100644 --- a/plugins/ui/src/deephaven/ui/_internal/__init__.py +++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py @@ -25,6 +25,10 @@ dict_to_camel_case, dict_to_react_props, remove_empty_keys, + dict_shallow_equal, + iterable_shallow_equal, + is_iterable, + 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..912fbf5df 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, @@ -8,6 +20,7 @@ from inspect import signature import sys from functools import partial +from itertools import zip_longest from deephaven.time import ( to_j_instant, to_j_zdt, @@ -949,3 +962,78 @@ 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 shallow_equal(value1: Any, value2: Any) -> bool: + """ + Check if two values are shallowly equal. + + Primitive values are compared by value (`==`) while non-primitive values are + compared by identity (`is`) to avoid deep comparisons on containers. Using `==` + for primitives avoids relying on CPython interning/caching details, which would + otherwise cause equal-but-distinct primitives (e.g. `int("1000")`) to be treated + as unequal and trigger unnecessary re-renders. + + Args: + value1: The first value to compare. + value2: The second value to compare. + + Returns: + True if the values are shallowly equal, False otherwise. + """ + if is_primitive(value1) and is_primitive(value2): + return type(value1) == type(value2) and value1 == value2 + return value1 is value2 + + +def iterable_shallow_equal(iter1: Any, iter2: Any) -> bool: + """ + Check if two standard iterable values are shallowly equal. + + Iterables are compared element-wise with `shallow_equal`: primitives by value + and non-primitives by identity. + + Args: + iter1: The first iterable to compare. + iter2: The second iterable to compare. + + Returns: + True if both values are standard iterables of the same type and their + elements are shallowly equal in order. + """ + if not is_iterable(iter1) or not is_iterable(iter2): + return False + + if type(iter1) is not type(iter2): + return False + + sentinel = object() + for value1, value2 in zip_longest(iter1, iter2, fillvalue=sentinel): + if value1 is sentinel or value2 is sentinel: + return False + if not shallow_equal(value1, value2): + return False + + return True + + +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. + + Values are compared with `shallow_equal`: primitives by value and non-primitives + by identity. + + Args: + dict1: The first dict to compare. + dict2: The second dict to compare. + + Returns: + True if the dictionaries are shallowly equal, False otherwise. + """ + if dict1.keys() != dict2.keys(): + return False + for key in dict1: + if not shallow_equal(dict1[key], 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..eda6e6991 100644 --- a/plugins/ui/src/deephaven/ui/components/component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -1,25 +1,148 @@ 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 -logger = logging.getLogger(__name__) +from .._internal import ( + get_component_qualname, + dict_shallow_equal, + shallow_equal, + iterable_shallow_equal, +) +from ..elements import Element, FunctionElement, MemoizedElement, PropsType +# Type alias for comparison functions +CompareFunction = Callable[[PropsType, PropsType], bool] -def component(func: Callable[..., Any]): + +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: + prev_children = prev_props.get("children") + next_children = next_props.get("children") + + # For list/tuple children, compare element-wise with shallow semantics. + if isinstance(prev_children, (list, tuple)) and isinstance( + next_children, (list, tuple) + ): + if not iterable_shallow_equal(prev_children, next_children): + return False + elif not shallow_equal(prev_children, next_children): + return False + + prev_props_without_children = dict(prev_props) + prev_props_without_children.pop("children", None) + + next_props_without_children = dict(next_props) + next_props_without_children.pop("children", None) + + return dict_shallow_equal( + prev_props_without_children, + next_props_without_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/Element.py b/plugins/ui/src/deephaven/ui/elements/Element.py index 61fc5599b..9d3a8a09b 100644 --- a/plugins/ui/src/deephaven/ui/elements/Element.py +++ b/plugins/ui/src/deephaven/ui/elements/Element.py @@ -35,6 +35,7 @@ def key(self) -> str | None: def render(self) -> PropsType: """ Renders this element, and returns the result as a dictionary of props for the element. + These props are then passed in to the client to render the `Element` with this `name`. If you just want to render children, pass back a dict with children only, e.g. { "children": ... } Returns: 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..88196cb61 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py @@ -0,0 +1,60 @@ +from __future__ import annotations +from typing import Callable + +from .Element import Element, PropsType + + +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..925265dac 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 pass should (re)render children (opening contexts / allowing new child contexts), versus a traversal pass that fetches existing child contexts and only re-renders dirty subtrees. 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_dict(item: PropsType, context: RenderContext) -> PropsType: +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. + is_dirty_render: Whether this pass should (re)render children (opening contexts / allowing new child contexts), versus a traversal pass that fetches existing child contexts and only re-renders dirty subtrees. + 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, 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,84 @@ 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 + + if isinstance(element, MemoizedElement): + # Memoized elements only need a fresh render when their state changed + # or their props are no longer equal. + needs_render = context.is_dirty or not element.are_props_equal(prev_props) + else: + # Non-memoized elements re-render when the parent is doing a dirty + # render or when this element's context is dirty. + needs_render = is_dirty_render or context.is_dirty + + if not needs_render: + # We can use the cached result without re-rendering this component. + # A child component may still need to be re-rendered if its context is dirty (e.g. child has a state change), + # but we can skip re-rendering this component if its props are the same and the context is not 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 +240,14 @@ 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. + + Key points: + - The Renderer executes Element.render() within a RenderContext to generate the realized Document tree. + - Each Element has a RenderContext that is destroyed when the Element unmounts. + - The RenderContext caches the previous rendered result of an Element. + - State changes mark the RenderContext as dirty, triggering re-render of the Element and its children. + - MemoizedElements only re-render if props change or the context is dirty. + This ensures only the necessary parts of the tree are re-rendered. """ _context: RenderContext @@ -153,4 +268,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..ff1e3059f 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,53 @@ 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", True), + [0, 1, 2], + ) + + @ui.component + def my_comp(): + return "Hello" + + @dataclass + class MyDataclass: + a: str + b: Element + + nested_dataclass = _render_child_item( + [MyDataclass("test", my_comp())], rc, "key", True + )[0] + + self.assertEqual( + nested_dataclass["a"], + "test", + ) + + self.assertIsInstance(nested_dataclass["b"], RenderedNode) + + def test_render_child_item_not_dirty(self): + rc = RenderContext(_TestRoot(Mock(), Mock())) + + # Prime context with an initial dirty render so fetch_only can read cache. + _render_child_item({"key": "value"}, rc, "dict_key", True) + + # Test with is_dirty_render=False for dict + self.assertEqual( + _render_child_item({"key": "value"}, rc, "dict_key", False), {"key": "value"}, ) + # Prime context with an initial dirty render so fetch_only can read cache. + _render_child_item([0, 1, 2], rc, "list_key", True) + + # Test with is_dirty_render=False for list self.assertEqual( - _render_child_item([0, 1, 2], rc, "key"), + _render_child_item([0, 1, 2], rc, "list_key", False), [0, 1, 2], ) @@ -254,8 +293,11 @@ class MyDataclass: a: str b: Element + # Prime context with an initial dirty render so fetch_only can read cache. + _render_child_item([MyDataclass("test", my_comp())], rc, "dataclass_key", True) + nested_dataclass = _render_child_item( - [MyDataclass("test", my_comp())], rc, "key" + [MyDataclass("test", my_comp())], rc, "dataclass_key", False )[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..e78b196ff 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -6,13 +6,16 @@ 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, convert_date_for_labeled_value, is_primitive, is_iterable, + iterable_shallow_equal, remove_empty_keys, + shallow_equal, to_camel_case, to_react_prop_case, wrap_callable, @@ -471,6 +474,155 @@ 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}) + ) + + # Primitives: equal but non-identical values should be equal (compared by value). + # int("1000") is outside CPython's small-int cache, so the two ints are + # distinct objects (different identity) but equal in value. + big1 = int("1000") + big2 = int("1000") + self.assertIsNot(big1, big2) + self.assertTrue(dict_shallow_equal({"count": big1}, {"count": big2})) + + # Equal but non-identical strings should also be equal + str1 = "hello world!".upper() + str2 = "hello world!".upper() + self.assertIsNot(str1, str2) + self.assertTrue(dict_shallow_equal({"msg": str1}, {"msg": str2})) + + # Equal but non-identical floats should also be equal + self.assertTrue(dict_shallow_equal({"f": 1.5 + 0.5}, {"f": 1.0 + 1.0})) + + # Different primitive values + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 2})) + self.assertFalse(dict_shallow_equal({"a": "foo"}, {"a": "bar"})) + + # A primitive and a non-primitive that are not identical should not be equal + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": "1"})) + + # 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})) + + def test_iterable_shallow_equal(self): + # Two empty iterables of same type are equal + self.assertTrue(iterable_shallow_equal([], [])) + self.assertTrue(iterable_shallow_equal((), ())) + + # Different iterable types are not equal + self.assertFalse(iterable_shallow_equal([], ())) + + # Length mismatch is not equal + self.assertFalse(iterable_shallow_equal([1], [1, 2])) + + # Primitives are compared by value + big1 = int("1000") + big2 = int("1000") + self.assertIsNot(big1, big2) + self.assertTrue(iterable_shallow_equal([big1, "x"], [big2, "x"])) + + # Non-primitives are compared by identity + shared_obj = {"nested": "value"} + self.assertTrue(iterable_shallow_equal([shared_obj], [shared_obj])) + self.assertFalse( + iterable_shallow_equal( + [{"nested": "value"}], + [{"nested": "value"}], + ) + ) + + # Non-iterables should return False + self.assertFalse(iterable_shallow_equal(1, 1)) + + def test_shallow_equal(self): + # Identical primitive values are equal + self.assertTrue(shallow_equal(1, 1)) + self.assertTrue(shallow_equal("hello", "hello")) + self.assertTrue(shallow_equal(None, None)) + self.assertTrue(shallow_equal(True, True)) + self.assertTrue(shallow_equal(1.5, 1.5)) + + # Equal but non-identical primitives are equal (compared by value). + # int("1000") is outside CPython's small-int cache, so the two ints are + # distinct objects (different identity) but equal in value. + big1 = int("1000") + big2 = int("1000") + self.assertIsNot(big1, big2) + self.assertTrue(shallow_equal(big1, big2)) + + str1 = "hello world!".upper() + str2 = "hello world!".upper() + self.assertIsNot(str1, str2) + self.assertTrue(shallow_equal(str1, str2)) + + self.assertTrue(shallow_equal(1.5 + 0.5, 1.0 + 1.0)) + + # Different primitive values are not equal + self.assertFalse(shallow_equal(1, 2)) + self.assertFalse(shallow_equal("foo", "bar")) + + # A primitive and a non-primitive that are not identical are not equal + self.assertFalse(shallow_equal(1, "1")) + + # Primitive values with different types should not be equal + self.assertFalse(shallow_equal(1, 1.0)) + self.assertFalse(shallow_equal("1", 1)) + + # Non-primitives are compared by identity, not value + obj = {"nested": "value"} + self.assertTrue(shallow_equal(obj, obj)) + self.assertFalse(shallow_equal({"nested": "value"}, {"nested": "value"})) + + lst = [1, 2, 3] + self.assertTrue(shallow_equal(lst, lst)) + self.assertFalse(shallow_equal([1, 2, 3], [1, 2, 3])) + + # Same function object is equal, different function objects are not + def my_func(): + pass + + def my_func2(): + pass + + self.assertTrue(shallow_equal(my_func, my_func)) + self.assertFalse(shallow_equal(my_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..27623406e --- /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. + await expect(randomValue).toHaveText(/^Random: \d+$/); + const initialRandom = (await randomValue.textContent()) ?? ''; + + // 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 ?? ''); +});