From 4492fd0064cd6f8c9bb605c3067d939f07a7406d Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 6 Feb 2026 10:10:09 -0500 Subject: [PATCH 01/19] Add component memoization implementation plan Two options for props-based memoization to skip re-renders: - Option A: @ui.memo decorator (familiar to React devs) - Option B: @ui.component(memo=True|compare_fn) parameter (cleaner) Includes: - API design and implementation details - MemoizedFunctionElement and Renderer changes - Unit tests for both options - Performance benchmarks - Comparison and recommendation (implement both) --- plans/component-memoization.md | 1265 ++++++++++++++++++++++++++++++++ 1 file changed, 1265 insertions(+) create mode 100644 plans/component-memoization.md diff --git a/plans/component-memoization.md b/plans/component-memoization.md new file mode 100644 index 000000000..ddc1aa1fe --- /dev/null +++ b/plans/component-memoization.md @@ -0,0 +1,1265 @@ +# 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 + +--- + +## 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) From 5e5c40463b7acd069fdbdf55ecc6face2969bc19 Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 9 Feb 2026 21:36:46 -0500 Subject: [PATCH 02/19] WIP add ui.memo functionality - Checks props and if they're the same, just return the previously rendered node - Still need to clean up the `_default_are_props_equal` and how children are handled, I think? - Also need to add a bunch of unit tests. But it more or less works! ``` from deephaven import ui def are_props_equal(old_props, new_props): print(f"Checking props {old_props} vs {new_props}") return old_props == new_props @ui.component def foo_component(name): value, set_value = ui.use_state(0) print(f"foo {name} render") return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1)) @ui.memo(are_props_equal=are_props_equal) @ui.component def memo_foo_component(name): value, set_value = ui.use_state(0) print(f"memo_foo {name} render") return ui.button(f"foo {name} {value}", on_press=lambda: set_value(value+1)) memo_foo = ui.memo()(foo_component) @ui.component def bar_component(): value, set_value = ui.use_state(0) return ui.flex( foo_component("A"), foo_component("B"), memo_foo_component("X"), memo_foo("Y"), ui.button(f"bar {value}", on_press=lambda: set_value(value+1)) ) mf = memo_foo_component("mf") b = bar_component() ``` --- .../deephaven/ui/_internal/RenderContext.py | 48 +++++++++++- .../ui/src/deephaven/ui/_internal/__init__.py | 1 + .../ui/src/deephaven/ui/_internal/utils.py | 16 ++++ .../src/deephaven/ui/components/__init__.py | 4 +- .../src/deephaven/ui/components/calendar.py | 4 +- .../{make_component.py => component.py} | 2 +- .../src/deephaven/ui/components/date_field.py | 4 +- .../deephaven/ui/components/date_picker.py | 4 +- .../ui/components/date_range_picker.py | 4 +- .../ui/src/deephaven/ui/components/memo.py | 77 +++++++++++++++++++ .../deephaven/ui/components/range_calendar.py | 4 +- .../ui/src/deephaven/ui/components/table.py | 2 +- .../src/deephaven/ui/components/time_field.py | 4 +- .../src/deephaven/ui/elements/BaseElement.py | 2 +- .../ui/src/deephaven/ui/elements/Element.py | 5 +- .../deephaven/ui/elements/FunctionElement.py | 6 +- .../deephaven/ui/elements/MemoizedElement.py | 64 +++++++++++++++ .../src/deephaven/ui/elements/UriElement.py | 2 +- .../ui/src/deephaven/ui/elements/__init__.py | 3 + .../ui/src/deephaven/ui/renderer/Renderer.py | 48 +++++++++++- plugins/ui/test/deephaven/ui/test_utils.py | 47 +++++++++++ 21 files changed, 321 insertions(+), 30 deletions(-) rename plugins/ui/src/deephaven/ui/components/{make_component.py => component.py} (93%) create mode 100644 plugins/ui/src/deephaven/ui/components/memo.py create mode 100644 plugins/ui/src/deephaven/ui/elements/MemoizedElement.py diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 3a2a797b3..7ffbce475 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -92,7 +92,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 @@ -221,6 +221,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, on_change: OnChangeCallable, on_queue_render: OnChangeCallable): """ Create a new render context. @@ -242,6 +252,8 @@ def __init__(self, on_change: OnChangeCallable, on_queue_render: OnChangeCallabl self._collected_contexts = [] self._top_level_scope = None self._is_mounted = True + self._is_dirty = True + self._cache = None def __del__(self): logger.debug("Deleting context") @@ -298,6 +310,9 @@ def open(self) -> Generator[RenderContext, None, None]: with self._top_level_scope.open(): yield self + # Reset the dirty state before processing effects, so that any state changes in effects will mark the context as dirty for the next render. + self._is_dirty = False + # Release all child contexts that are no longer referenced for context_key in old_contexts: if context_key not in self._collected_contexts: @@ -378,6 +393,16 @@ def _assert_mounted(self) -> None: "RenderContext method called when RenderContext is unmounted" ) + @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 has_state(self, key: StateKey) -> bool: """ Check if the given key is in the state. @@ -441,6 +466,7 @@ def update_state(): # This is not the initial state, queue up the state change on the render loop self._on_change(update_state) + self._is_dirty = True def get_child_context(self, key: ContextKey) -> "RenderContext": """ @@ -585,3 +611,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 c5e6cc59a..f5c86caab 100644 --- a/plugins/ui/src/deephaven/ui/_internal/__init__.py +++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py @@ -22,5 +22,6 @@ dict_to_camel_case, dict_to_react_props, remove_empty_keys, + dict_shallow_equal, wrap_callable, ) diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 4eafc01a3..c28b2fc1e 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -950,3 +950,19 @@ def is_iterable(value: Any) -> bool: True if the value is a standard iterable type. """ return isinstance(value, (list, tuple, set, dict, map, filter, range)) + + +def dict_shallow_equal(dict1: dict[str, Any], dict2: dict[str, Any]) -> bool: + """ + Check if two dictionaries are shallowly equal. By default Python does a deep equals check, but for props comparison we may just want a shallow equals. + + Args: + dict1: The first dict to compare. + dict2: The second dict to compare. + """ + if dict1.keys() != dict2.keys(): + return False + for key in dict1: + if dict1[key] is not dict2[key]: + return False + return True diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index e701a1da7..5bbd11a33 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -48,8 +48,9 @@ from .list_action_menu import list_action_menu from .list_view import list_view from .logic_button import logic_button -from .make_component import make_component as component +from .component import component from .markdown import markdown +from .memo import memo from .menu import menu from .menu_trigger import menu_trigger from .meter import meter @@ -138,6 +139,7 @@ "logic_button", "html", "markdown", + "memo", "menu", "menu_trigger", "meter", diff --git a/plugins/ui/src/deephaven/ui/components/calendar.py b/plugins/ui/src/deephaven/ui/components/calendar.py index 400c54494..e8f0a664a 100644 --- a/plugins/ui/src/deephaven/ui/components/calendar.py +++ b/plugins/ui/src/deephaven/ui/components/calendar.py @@ -17,7 +17,7 @@ from .._internal.utils import create_props, convert_date_props, wrap_local_date_callable from ..types import Date, LocalDateConvertible, Undefined, UndefinedType from .basic import component_element -from .make_component import make_component +from .component import component CalendarElement = Element @@ -74,7 +74,7 @@ def _convert_calendar_props( return props -@make_component +@component def calendar( value: Date | None | UndefinedType = Undefined, default_value: Date | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/make_component.py b/plugins/ui/src/deephaven/ui/components/component.py similarity index 93% rename from plugins/ui/src/deephaven/ui/components/make_component.py rename to plugins/ui/src/deephaven/ui/components/component.py index 4d045b349..2c52b6777 100644 --- a/plugins/ui/src/deephaven/ui/components/make_component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def make_component(func: Callable[..., Any]): +def component(func: Callable[..., Any]): """ Create a FunctionalElement from the passed in function. diff --git a/plugins/ui/src/deephaven/ui/components/date_field.py b/plugins/ui/src/deephaven/ui/components/date_field.py index 8dafbd244..3c11673a5 100644 --- a/plugins/ui/src/deephaven/ui/components/date_field.py +++ b/plugins/ui/src/deephaven/ui/components/date_field.py @@ -27,7 +27,7 @@ ) from ..types import Date, Granularity, Undefined, UndefinedType from .basic import component_element -from .make_component import make_component +from .component import component from deephaven.time import dh_now DateFieldElement = Element @@ -75,7 +75,7 @@ def _convert_date_field_props( return props -@make_component +@component def date_field( placeholder_value: Date | None = dh_now(), value: Date | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py index 8520308d9..94232265b 100644 --- a/plugins/ui/src/deephaven/ui/components/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -31,7 +31,7 @@ ) from ..types import Date, Granularity, Undefined, UndefinedType from .basic import component_element -from .make_component import make_component +from .component import component DatePickerElement = Element @@ -79,7 +79,7 @@ def _convert_date_picker_props( return props -@make_component +@component def date_picker( placeholder_value: Date | None = None, value: Date | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/date_range_picker.py b/plugins/ui/src/deephaven/ui/components/date_range_picker.py index acd56feb2..d092c71a8 100644 --- a/plugins/ui/src/deephaven/ui/components/date_range_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_range_picker.py @@ -31,7 +31,7 @@ ) from ..types import Date, Granularity, DateRange, Undefined, UndefinedType from .basic import component_element -from .make_component import make_component +from .component import component from deephaven.time import dh_now DatePickerElement = Element @@ -78,7 +78,7 @@ def _convert_date_range_picker_props( return props -@make_component +@component def date_range_picker( placeholder_value: Date | None = None, value: DateRange | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/memo.py b/plugins/ui/src/deephaven/ui/components/memo.py new file mode 100644 index 000000000..6d491f32c --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/memo.py @@ -0,0 +1,77 @@ +from __future__ import annotations +import functools +import logging +from typing import Any, Callable + +from .._internal import dict_shallow_equal + +from ..elements import MemoizedElement, PropsType + +logger = logging.getLogger(__name__) + + +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. + """ + # Need to check the children separately, because they are passed in as a list and the list will be a different object each time even if the children are the same. + if "children" in prev_props and "children" in next_props: + prev_children = prev_props["children"] + next_children = next_props["children"] + + if not prev_children == next_children: + return False + + # Now we just need to do a dict_shallow_equal with all the other props that aren't children + return dict_shallow_equal( + {k: v for k, v in prev_props.items() if k != "children"}, + {k: v for k, v in next_props.items() if k != "children"}, + ) + + +def memo( + are_props_equal: Callable[[PropsType, PropsType], bool] = _default_are_props_equal, +): + """ + Create a MemoizedElement from the passed in function. + + A MemoizedElement is a component that will only re-render if its props have changed or if the context it is in is dirty (e.g. state has changed). + This can be used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. + + Args: + 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. + """ + + def memo_func( + func: Callable[..., Any], + ): + """ + Create a MemoizedElement from the passed in function. + + A MemoizedElement is a component that will only re-render if its props have changed or if the context it is in is dirty (e.g. state has changed). + This can be used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. + + Args: + func: The function to create a MemoizedElement from. + Runs when the component is being rendered. + """ + + @functools.wraps(func) + def make_memoized_node(*args: Any, **kwargs: Any): + element = func(*args, **kwargs) + return MemoizedElement( + element, + {"children": [*args], **kwargs}, + are_props_equal, + ) + + return make_memoized_node + + return memo_func diff --git a/plugins/ui/src/deephaven/ui/components/range_calendar.py b/plugins/ui/src/deephaven/ui/components/range_calendar.py index f006e3f9e..5e4aa51c1 100644 --- a/plugins/ui/src/deephaven/ui/components/range_calendar.py +++ b/plugins/ui/src/deephaven/ui/components/range_calendar.py @@ -23,7 +23,7 @@ UndefinedType, ) from .basic import component_element -from .make_component import make_component +from .component import component RangeCalendarElement = Element @@ -78,7 +78,7 @@ def _convert_range_calendar_props( return props -@make_component +@component def range_calendar( value: DateRange | None | UndefinedType = Undefined, default_value: DateRange | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/table.py b/plugins/ui/src/deephaven/ui/components/table.py index 765af4ee6..63bd85006 100644 --- a/plugins/ui/src/deephaven/ui/components/table.py +++ b/plugins/ui/src/deephaven/ui/components/table.py @@ -313,6 +313,6 @@ def name(self): def key(self) -> str | None: return self._key - def render(self, context: RenderContext) -> dict[str, Any]: + def render(self) -> dict[str, Any]: logger.debug("Returning props %s", self._props) return dict_to_react_props(self._props) diff --git a/plugins/ui/src/deephaven/ui/components/time_field.py b/plugins/ui/src/deephaven/ui/components/time_field.py index 91d1129d5..dd1da6b01 100644 --- a/plugins/ui/src/deephaven/ui/components/time_field.py +++ b/plugins/ui/src/deephaven/ui/components/time_field.py @@ -27,7 +27,7 @@ ) from ..types import Time, TimeGranularity, Undefined, UndefinedType from .basic import component_element -from .make_component import make_component +from .component import component TimeFieldElement = Element @@ -70,7 +70,7 @@ def _convert_time_field_props( return props -@make_component +@component def time_field( placeholder_value: Time | None = None, value: Time | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/elements/BaseElement.py b/plugins/ui/src/deephaven/ui/elements/BaseElement.py index 9cc5a0163..1c3fcc03e 100644 --- a/plugins/ui/src/deephaven/ui/elements/BaseElement.py +++ b/plugins/ui/src/deephaven/ui/elements/BaseElement.py @@ -50,5 +50,5 @@ def name(self) -> str: def key(self) -> str | None: return self._key - def render(self, context: RenderContext) -> dict[str, Any]: + def render(self) -> dict[str, Any]: return self._props diff --git a/plugins/ui/src/deephaven/ui/elements/Element.py b/plugins/ui/src/deephaven/ui/elements/Element.py index d094ae7fc..6caf216b5 100644 --- a/plugins/ui/src/deephaven/ui/elements/Element.py +++ b/plugins/ui/src/deephaven/ui/elements/Element.py @@ -33,14 +33,11 @@ def key(self) -> str | None: return None @abstractmethod - def render(self, context: RenderContext) -> PropsType: + def render(self) -> PropsType: """ Renders this element, and returns the result as a dictionary of props for the element. If you just want to render children, pass back a dict with children only, e.g. { "children": ... } - Args: - context: Deprecated. The context to render the element in. Should already be opened before calling this method. - Returns: The props of this element. """ diff --git a/plugins/ui/src/deephaven/ui/elements/FunctionElement.py b/plugins/ui/src/deephaven/ui/elements/FunctionElement.py index 6b1ea35f9..08377f4e5 100644 --- a/plugins/ui/src/deephaven/ui/elements/FunctionElement.py +++ b/plugins/ui/src/deephaven/ui/elements/FunctionElement.py @@ -17,6 +17,7 @@ def __init__( Args: name: Name of the component. Typically, the module joined with the name of the function. render: The render function to call when the component needs to be rendered. + key: The key of this element. """ self._name = name self._render = render @@ -30,13 +31,10 @@ def name(self): def key(self) -> str | None: return self._key - def render(self, context: RenderContext) -> PropsType: + def render(self) -> PropsType: """ Render the component. Should only be called when actually rendering the component, e.g. exporting it to the client. - Args: - context: Context to render the component in - Returns: The props of this element. """ diff --git a/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py b/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py new file mode 100644 index 000000000..cc3d10aed --- /dev/null +++ b/plugins/ui/src/deephaven/ui/elements/MemoizedElement.py @@ -0,0 +1,64 @@ +from __future__ import annotations +import logging +from typing import Callable, Optional + +from .Element import Element, PropsType +from .._internal import dict_shallow_equal, RenderContext + +logger = logging.getLogger(__name__) + + +class MemoizedElement(Element): + _element: Element + _props: PropsType + _are_props_equal: Callable[[PropsType, PropsType], bool] + + def __init__( + self, + element: Element, + props: PropsType, + are_props_equal: Callable[[PropsType, PropsType], bool], + ): + """ + Create an element that takes a function to render. + + Args: + element: The element to memoize. + props: The props of the element. + are_props_equal: A function that takes the previous props and the next props and returns whether they are equal. If the props are equal, the component will not re-render. If the props are not equal, the component will re-render. This is used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. + """ + self._element = element + self._props = props + self._are_props_equal = are_props_equal + + @property + def name(self): + return self._element.name + + @property + def key(self) -> str | None: + return self._element.key + + @property + def props(self) -> PropsType: + return self._props + + def are_props_equal(self, prev_props: PropsType) -> bool: + """ + Check if the props are equal using the are_props_equal function. + + Args: + prev_props: The previous props to check against the current props. + Returns: + True if the props are equal, False otherwise. + """ + return self._are_props_equal(prev_props, self._props) + + def render(self) -> PropsType: + """ + Render the component. Should only be called when actually rendering the component, e.g. exporting it to the client. + + Returns: + The props of this element. + """ + return self._element.render() diff --git a/plugins/ui/src/deephaven/ui/elements/UriElement.py b/plugins/ui/src/deephaven/ui/elements/UriElement.py index 7eaf53c7f..ad8268bae 100644 --- a/plugins/ui/src/deephaven/ui/elements/UriElement.py +++ b/plugins/ui/src/deephaven/ui/elements/UriElement.py @@ -29,7 +29,7 @@ def name(self) -> str: def key(self) -> str | None: return self._key - def render(self, context: RenderContext) -> PropsType: + def render(self) -> PropsType: return {"uri": self._uri} def __eq__(self, other: object) -> bool: diff --git a/plugins/ui/src/deephaven/ui/elements/__init__.py b/plugins/ui/src/deephaven/ui/elements/__init__.py index 78eef2f88..110b366b3 100644 --- a/plugins/ui/src/deephaven/ui/elements/__init__.py +++ b/plugins/ui/src/deephaven/ui/elements/__init__.py @@ -2,6 +2,7 @@ from .BaseElement import BaseElement from .DashboardElement import DashboardElement from .FunctionElement import FunctionElement +from .MemoizedElement import MemoizedElement from .UriElement import resolve __all__ = [ @@ -9,6 +10,8 @@ "DashboardElement", "Element", "FunctionElement", + "MemoizedElement", + "NodeType", "PropsType", "resolve", ] diff --git a/plugins/ui/src/deephaven/ui/renderer/Renderer.py b/plugins/ui/src/deephaven/ui/renderer/Renderer.py index 5f7161adb..832c2b169 100644 --- a/plugins/ui/src/deephaven/ui/renderer/Renderer.py +++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py @@ -4,7 +4,7 @@ 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__) @@ -116,15 +116,55 @@ def _render_element(element: Element, context: RenderContext) -> RenderedNode: 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, + ) + + element_props = None + + if isinstance(element, MemoizedElement): + element_props = element.props + + if element_props is not None and context.cache is not None: + logger.debug( + "Element is a MemoizedElement, checking if it needs to be re-rendered" + ) + + prev_props, prev_rendered_node = context.cache + + 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) + ): + logger.debug( + "MemoizedElement props are equal and context is not dirty, returning cached rendered node" + ) + return prev_rendered_node + + logger.debug( + "MemoizedElement props have changed or context is dirty, re-rendering element" + ) with context.open(): - props = element.render(context) + props = element.render() # We also need to render any elements that are passed in as props (including `children`) props = _render_dict_in_open_context(props, context) - return RenderedNode(element.name, props) + rendered_node = RenderedNode(element.name, props) + + logger.debug("Rendered element %s with input props %s", element.name, element_props) + + if isinstance(element, MemoizedElement) and element_props is not None: + context.cache = (element_props, rendered_node) + + return rendered_node class Renderer: diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py index 31697a2da..421abe10c 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -6,6 +6,7 @@ from deephaven.ui._internal.utils import ( convert_dict_keys, create_props, + dict_shallow_equal, dict_to_camel_case, dict_to_react_props, get_component_name, @@ -471,6 +472,52 @@ def __iter__(self): self.assertFalse(is_iterable(CustomIterable())) + def test_dict_shallow_equal(self): + # Two empty dicts are equal + self.assertTrue(dict_shallow_equal({}, {})) + + # Same keys with identical values (same object) should be equal + obj1 = {"nested": "value"} + obj2 = [1, 2, 3] + dict1 = {"a": obj1, "b": obj2} + dict2 = {"a": obj1, "b": obj2} + self.assertTrue(dict_shallow_equal(dict1, dict2)) + + # Same keys with equal but not identical values should NOT be equal + dict3 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + dict4 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + self.assertFalse(dict_shallow_equal(dict3, dict4)) + + # Different keys should not be equal + self.assertFalse(dict_shallow_equal({"a": 1}, {"b": 1})) + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 1, "b": 2})) + self.assertFalse(dict_shallow_equal({"a": 1, "b": 2}, {"a": 1})) + + # Primitives: small ints and interned strings have the same identity + self.assertTrue( + dict_shallow_equal({"a": 1, "b": "hello"}, {"a": 1, "b": "hello"}) + ) + self.assertTrue(dict_shallow_equal({"x": None}, {"x": None})) + self.assertTrue( + dict_shallow_equal({"x": True, "y": False}, {"x": True, "y": False}) + ) + + # Different primitive values + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 2})) + self.assertFalse(dict_shallow_equal({"a": "foo"}, {"a": "bar"})) + + # Test with callables - same function object + def my_func(): + pass + + self.assertTrue(dict_shallow_equal({"func": my_func}, {"func": my_func})) + + # Different function objects (even with same behavior) should NOT be equal + def my_func2(): + pass + + self.assertFalse(dict_shallow_equal({"func": my_func}, {"func": my_func2})) + if __name__ == "__main__": unittest.main() From ca3c73be71a15ec565fa6082300dc1fbce91736f Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 10 Feb 2026 09:22:52 -0500 Subject: [PATCH 03/19] Fix unit tests --- plugins/ui/test/deephaven/ui/test_ui_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index 1e0323d6d..b62ad5253 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -17,7 +17,7 @@ def expect_render(self, ui_table, expected_props: dict[str, Any]): on_change = Mock() on_queue = Mock() context = RenderContext(on_change, on_queue) - result = ui_table.render(context) + result = ui_table.render() self.assertDictEqual(result, result | expected_props) From 16871f14fd55d51214a7ffe45e9cd56ef8d8d62e Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 10 Feb 2026 10:02:09 -0500 Subject: [PATCH 04/19] test: Add unit tests for ui.memo decorator --- plugins/ui/test/deephaven/ui/test_memo.py | 663 ++++++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 plugins/ui/test/deephaven/ui/test_memo.py 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..b3287f3a6 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -0,0 +1,663 @@ +""" +Tests for component memoization (ui.memo decorator). + +The @ui.memo decorator 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, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoTestCase(BaseTestCase): + """Tests for component memoization (@ui.memo decorator).""" + + 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 @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.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_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] + + @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) + 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 @ui.memo() 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.memo() + @ui.component + 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 @ui.memo() 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.memo() + @ui.component + 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 @ui.memo() 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.memo() + @ui.component + 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.memo(are_props_equal=compare_only_value) + @ui.component + 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_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.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 (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.memo() + @ui.component + 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.memo() + @ui.component + def memoized_child(value: int): + child_count[0] += 1 + return ui.text(f"Child: {value}") + + @ui.memo() + @ui.component + 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.memo() + @ui.component + 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 always runs when we render grandparent() + self.assertEqual( + grandparent_count[0], 2 + ) # 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], 3 + ) # 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.memo() + @ui.component + 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.memo() + @ui.component + 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.memo() + @ui.component + 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.memo() + @ui.component + 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) + + +if __name__ == "__main__": + import unittest + + unittest.main() From 959b3fdbb322128a753e6ed8a3d30c73b6dfdd55 Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 10 Feb 2026 10:11:17 -0500 Subject: [PATCH 05/19] feat: Support @ui.memo without parentheses and add custom compare tests - Allow @ui.memo syntax in addition to @ui.memo() - Add tests for custom are_props_equal functions: - Deep equality comparison for object props - Always rerender (returns False) - Always skip (returns True) - Selective prop comparison - Threshold-based comparison --- .../ui/src/deephaven/ui/components/memo.py | 50 +++- plugins/ui/test/deephaven/ui/test_memo.py | 275 +++++++++++++++++- 2 files changed, 315 insertions(+), 10 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/memo.py b/plugins/ui/src/deephaven/ui/components/memo.py index 6d491f32c..8f889aba3 100644 --- a/plugins/ui/src/deephaven/ui/components/memo.py +++ b/plugins/ui/src/deephaven/ui/components/memo.py @@ -37,7 +37,11 @@ def _default_are_props_equal(prev_props: PropsType, next_props: PropsType) -> bo def memo( - are_props_equal: Callable[[PropsType, PropsType], bool] = _default_are_props_equal, + func_or_are_props_equal: ( + Callable[..., Any] | Callable[[PropsType, PropsType], bool] | None + ) = None, + *, + are_props_equal: Callable[[PropsType, PropsType], bool] | None = None, ): """ Create a MemoizedElement from the passed in function. @@ -45,9 +49,37 @@ def memo( A MemoizedElement is a component that will only re-render if its props have changed or if the context it is in is dirty (e.g. state has changed). This can be used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. + Can be used in several ways: + + 1. Without parentheses (uses default shallow comparison): + @ui.memo + @ui.component + def my_component(value): + return ui.text(str(value)) + + 2. With parentheses (uses default shallow comparison): + @ui.memo() + @ui.component + def my_component(value): + return ui.text(str(value)) + + 3. With custom comparison function: + @ui.memo(are_props_equal=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: - 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. + func_or_are_props_equal: Either the component function (when used without parentheses) + or None (when used with parentheses). + 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 not provided, uses shallow equality comparison. """ + # Determine the actual comparison function to use + compare_fn = ( + are_props_equal if are_props_equal is not None else _default_are_props_equal + ) def memo_func( func: Callable[..., Any], @@ -69,9 +101,19 @@ def make_memoized_node(*args: Any, **kwargs: Any): return MemoizedElement( element, {"children": [*args], **kwargs}, - are_props_equal, + compare_fn, ) return make_memoized_node - return memo_func + # Handle the different usage patterns + if func_or_are_props_equal is None: + # Called as @ui.memo() with optional are_props_equal kwarg + return memo_func + elif callable(func_or_are_props_equal): + # Called as @ui.memo without parentheses - func_or_are_props_equal is the decorated function + return memo_func(func_or_are_props_equal) + else: + raise TypeError( + f"memo() expected a callable or None, got {type(func_or_are_props_equal).__name__}" + ) diff --git a/plugins/ui/test/deephaven/ui/test_memo.py b/plugins/ui/test/deephaven/ui/test_memo.py index b3287f3a6..b37a319ca 100644 --- a/plugins/ui/test/deephaven/ui/test_memo.py +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -41,14 +41,14 @@ 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 @ui.memo() skips re-render when props are unchanged.""" + """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.memo @ui.component def memoized_child(value: int): child_render_count[0] += 1 @@ -85,13 +85,13 @@ def parent(): 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.""" + """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] - @ui.memo() + @ui.memo @ui.component def memoized_child(value: int): child_render_count[0] += 1 @@ -123,13 +123,13 @@ def parent(): self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) def test_memo_rerenders_when_own_state_changes(self): - """Test that @ui.memo() re-renders when the memoized component's own state changes.""" + """Test that @ui.memo 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.memo() + @ui.memo @ui.component def memoized_child(value: int): child_render_count[0] += 1 @@ -289,6 +289,221 @@ def 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.memo(are_props_equal=deep_equal_items) + @ui.component + 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.memo(are_props_equal=always_different) + @ui.component + 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.memo(are_props_equal=always_equal) + @ui.component + 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.memo(are_props_equal=compare_important_only) + @ui.component + 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] + + # 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.memo(are_props_equal=significant_change_only) + @ui.component + def threshold_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + value_ref = [0] + + @ui.component + def parent(): + return ui.flex( + threshold_child(value_ref[0]), + ) + + 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 + value_ref[0] = 3 + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Another small change - should skip + value_ref[0] = 5 + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Big change (exceeds threshold) - should re-render + value_ref[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) @@ -656,6 +871,54 @@ def parent(): # Non-memoized child should re-render even with same props self.assertEqual(child_render_count[0], 2) + def test_memo_both_syntaxes_work(self): + """Test that both @ui.memo and @ui.memo() syntaxes work identically.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + no_parens_count = [0] + with_parens_count = [0] + + # Syntax without parentheses + @ui.memo + @ui.component + def memoized_no_parens(value: int): + no_parens_count[0] += 1 + return ui.text(f"No parens: {value}") + + # Syntax with parentheses + @ui.memo() + @ui.component + def memoized_with_parens(value: int): + with_parens_count[0] += 1 + return ui.text(f"With parens: {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_no_parens(value=42), + memoized_with_parens(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(no_parens_count[0], 1) + self.assertEqual(with_parens_count[0], 1) + + # Trigger parent re-render with same props to children + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Both should skip re-render + self.assertEqual(no_parens_count[0], 1) + self.assertEqual(with_parens_count[0], 1) + if __name__ == "__main__": import unittest From 178229a96e2ab157ac2bffbebce024fc07eec40c Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 10 Feb 2026 10:38:06 -0500 Subject: [PATCH 06/19] docs: Add documentation for @ui.memo component memoization - Update render-cycle.md with section on optimizing re-renders - Create memoizing-components.md with comprehensive guide: - Basic usage and how memoization works - When to use @ui.memo - Custom comparison with are_props_equal - Common pitfalls (new objects, callbacks) - Comparison with use_memo hook - Add memoizing-components to sidebar navigation --- .../add-interactivity/memoizing-components.md | 328 ++++++++++++++++++ .../ui/docs/add-interactivity/render-cycle.md | 37 ++ plugins/ui/docs/sidebar.json | 4 + 3 files changed, 369 insertions(+) create mode 100644 plugins/ui/docs/add-interactivity/memoizing-components.md 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..c6787766a --- /dev/null +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -0,0 +1,328 @@ +# Memoizing Components + +`@ui.memo` is a decorator that 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] > `@ui.memo` 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 + +Wrap your component with `@ui.memo` to skip re-renders when props are unchanged: + +```python +from deephaven import ui + + +@ui.memo +@ui.component +def greeting(name): + print(f"Rendering greeting for {name}") + return ui.text(f"Hello, {name}!") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + greeting("World"), # Won't re-render when count changes + direction="column", + ) + + +app_example = app() +``` + +In this example, clicking the button increments `count`, causing `app` to re-render. However, `greeting` will not re-render because its prop (`"World"`) hasn't changed. + +## How It Works + +By default, when a parent component re-renders, all of its child components re-render too. With `@ui.memo`, `deephaven.ui` compares the new props with the previous props using shallow equality. If all props are equal, the component skips rendering and reuses its previous result. + +The render cycle with memoization: + +1. **Trigger**: Parent component state changes +2. **Render**: Parent re-renders, but memoized children with unchanged props are skipped +3. **Commit**: Only changed parts of the UI are updated + +## When to Use `@ui.memo` + +Use `@ui.memo` when: + +- A component renders often with the same props +- A component is expensive to render (complex calculations, many children) +- A parent component re-renders frequently but passes stable props to children + +Don't use `@ui.memo` when: + +- The component's props change on almost every render +- The component is cheap to render +- You're prematurely optimizing without measuring performance + +```python +from deephaven import ui + + +# Good candidate: renders same static content while parent updates +@ui.memo +@ui.component +def expensive_chart(data): + # Imagine this does complex data processing + return ui.text(f"Chart with {len(data)} points") + + +# Not a good candidate: props change every render +@ui.component +def live_counter(count): + return ui.text(f"Count: {count}") + + +@ui.component +def dashboard(): + count, set_count = ui.use_state(0) + chart_data = [1, 2, 3, 4, 5] # Static data + + return ui.flex( + ui.button("Update", on_press=lambda: set_count(count + 1)), + live_counter(count), # No benefit from memo - count always changes + expensive_chart(chart_data), # Benefits from memo - data is stable + direction="column", + ) + + +dashboard_example = dashboard() +``` + +## Custom Comparison with `are_props_equal` + +By default, `@ui.memo` uses shallow equality to compare props. You can provide a custom comparison function using the `are_props_equal` parameter: + +```python +from deephaven import ui + + +def compare_by_id(prev_props, next_props): + """Only re-render if the 'id' prop changes.""" + return prev_props.get("id") == next_props.get("id") + + +@ui.memo(are_props_equal=compare_by_id) +@ui.component +def user_card(id, name, last_updated): + return ui.flex( + ui.text(f"User #{id}"), + ui.text(f"Name: {name}"), + ui.text(f"Updated: {last_updated}"), + direction="column", + ) + + +@ui.component +def user_profile(): + name, set_name = ui.use_state("Alice") + timestamp, set_timestamp = ui.use_state("12:00") + + return ui.flex( + ui.button("Update timestamp", on_press=lambda: set_timestamp("12:01")), + ui.button("Change name", on_press=lambda: set_name("Bob")), + # Only re-renders if id changes, not name or last_updated + user_card(id=1, name=name, last_updated=timestamp), + direction="column", + ) + + +user_profile_example = user_profile() +``` + +The `are_props_equal` function receives two dictionaries: + +- `prev_props`: The props from the previous render +- `next_props`: The props for the current render + +Return `True` to skip re-rendering (props are "equal"), or `False` to re-render. + +### Deep Equality Comparison + +For props containing nested data structures, you might want deep equality: + +```python +from deephaven import ui + + +def deep_equal(prev_props, next_props): + """Compare props using deep equality.""" + import json + + return json.dumps(prev_props, sort_keys=True) == json.dumps( + next_props, sort_keys=True + ) + + +@ui.memo(are_props_equal=deep_equal) +@ui.component +def data_display(config): + return ui.text(f"Config: {config}") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + # Even though a new dict is created each render, deep_equal + # will detect the values are the same and skip re-rendering + data_display(config={"setting": "value", "enabled": True}), + direction="column", + ) + + +app_example = app() +``` + +### Threshold-Based Comparison + +You can implement more sophisticated comparison logic: + +```python +from deephaven import ui + + +def significant_change(prev_props, next_props, threshold=5): + """Only re-render if value changes by more than threshold.""" + prev_value = prev_props.get("value", 0) + next_value = next_props.get("value", 0) + return abs(next_value - prev_value) <= threshold + + +@ui.memo(are_props_equal=significant_change) +@ui.component +def progress_bar(value): + return ui.progress_bar(value=value, label=f"{value}%") + + +@ui.component +def app(): + value, set_value = ui.use_state(0) + + return ui.flex( + ui.button("+1", on_press=lambda: set_value(value + 1)), + ui.button("+10", on_press=lambda: set_value(value + 10)), + # Only re-renders when value changes by more than 5 + progress_bar(value=value), + direction="column", + ) + + +app_example = app() +``` + +## Decorator Syntax + +Both syntax forms are supported: + +```python +# Without parentheses (uses default shallow comparison) +@ui.memo +@ui.component +def my_component(prop): + return ui.text(prop) + + +# With parentheses (allows custom comparison) +@ui.memo() +@ui.component +def my_component_with_parens(prop): + return ui.text(prop) + + +# With custom comparison function +@ui.memo(are_props_equal=my_custom_compare) +@ui.component +def my_component_custom(prop): + return ui.text(prop) +``` + +## Common Pitfalls + +### Creating New Objects in Props + +When you pass a new object, list, or dictionary as a prop, it will always be a different reference, causing re-renders even if the content is the same: + +```python +from deephaven import ui + + +@ui.memo +@ui.component +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: Creates a new list on every render + # item_list will re-render every time even though content is the same + items_bad = ["apple", "banana"] + + # GOOD: Use use_memo to keep the same reference + items_good = ui.use_memo(lambda: ["apple", "banana"], []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + item_list(items_good), # Won't re-render unnecessarily + direction="column", + ) + + +app_example = app() +``` + +### Passing Callback Functions + +Lambda functions and inline function definitions create new references each render: + +```python +from deephaven import ui + + +@ui.memo +@ui.component +def button_row(on_click): + return ui.button("Click me", on_press=on_click) + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + # BAD: Creates a new function reference every render + # handle_click_bad = lambda: print("clicked") + + # GOOD: Use use_callback to memoize the function + handle_click_good = ui.use_callback(lambda: print("clicked"), []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + button_row(on_click=handle_click_good), # Won't re-render unnecessarily + direction="column", + ) + + +app_example = app() +``` + +## Comparison with `use_memo` + +| Feature | `@ui.memo` | `use_memo` | +| ------- | ----------------------------- | ---------------------- | +| Purpose | Skip re-rendering a component | Cache a computed value | +| Usage | Decorator on component | Hook inside component | +| Input | Component props | Dependencies array | +| Output | Memoized component | Memoized value | + +Use `@ui.memo` 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 c822dfb26..f62fb40fa 100644 --- a/plugins/ui/docs/add-interactivity/render-cycle.md +++ b/plugins/ui/docs/add-interactivity/render-cycle.md @@ -125,3 +125,40 @@ 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 `@ui.memo` + +By default, when any component's state changes, `deephaven.ui` re-renders the entire component tree from the root—not just the component that triggered the change or its children, but every component in the tree. This is usually not a problem, but if you have a deeply nested tree or expensive components, you can optimize performance by wrapping components with `@ui.memo`. + +The `@ui.memo` decorator tells `deephaven.ui` to skip re-rendering a component when its props haven't changed: + +```python +from deephaven import ui + + +@ui.memo +@ui.component +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 0c40e0db1..f723f7fe5 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" From 60e5fcbab184586788e51fcab3a55370a896c1b1 Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 14:54:16 -0500 Subject: [PATCH 07/19] refactor: Replace @ui.memo decorator with memo parameter on @ui.component - Modified component.py to add memo parameter (True/False/callable) - Removed standalone memo.py decorator - Updated components/__init__.py exports - Updated all tests to use @ui.component(memo=True) syntax - Updated documentation in memoizing-components.md and render-cycle.md --- .../add-interactivity/memoizing-components.md | 67 ++++----- .../ui/docs/add-interactivity/render-cycle.md | 9 +- .../src/deephaven/ui/components/__init__.py | 2 - .../src/deephaven/ui/components/component.py | 127 ++++++++++++++++-- .../ui/src/deephaven/ui/components/memo.py | 119 ---------------- plugins/ui/test/deephaven/ui/test_memo.py | 114 ++++++---------- 6 files changed, 191 insertions(+), 247 deletions(-) delete mode 100644 plugins/ui/src/deephaven/ui/components/memo.py diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md index c6787766a..241c772c0 100644 --- a/plugins/ui/docs/add-interactivity/memoizing-components.md +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -1,19 +1,19 @@ # Memoizing Components -`@ui.memo` is a decorator that 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. +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] > `@ui.memo` is for memoizing entire components. To memoize a value or computation within a component, use the [`use_memo`](../hooks/use_memo.md) hook instead. +> [!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 -Wrap your component with `@ui.memo` to skip re-renders when props are unchanged: +Add `memo=True` to your component to skip re-renders when props are unchanged: ```python from deephaven import ui -@ui.memo -@ui.component +@ui.component(memo=True) def greeting(name): print(f"Rendering greeting for {name}") return ui.text(f"Hello, {name}!") @@ -38,7 +38,7 @@ In this example, clicking the button increments `count`, causing `app` to re-ren ## How It Works -By default, when a parent component re-renders, all of its child components re-render too. With `@ui.memo`, `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. +By default, when a parent component re-renders, all of its child components re-render too. With `memo=True`, `deephaven.ui` compares the new props with the previous props using shallow equality. If all props are equal, the component skips rendering and reuses its previous result. The render cycle with memoization: @@ -46,15 +46,15 @@ The render cycle with memoization: 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 `@ui.memo` +## When to Use `memo` -Use `@ui.memo` when: +Use `memo=True` when: - A component renders often with the same props - A component is expensive to render (complex calculations, many children) - A parent component re-renders frequently but passes stable props to children -Don't use `@ui.memo` when: +Don't use `memo` when: - The component's props change on almost every render - The component is cheap to render @@ -65,8 +65,7 @@ from deephaven import ui # Good candidate: renders same static content while parent updates -@ui.memo -@ui.component +@ui.component(memo=True) def expensive_chart(data): # Imagine this does complex data processing return ui.text(f"Chart with {len(data)} points") @@ -94,9 +93,9 @@ def dashboard(): dashboard_example = dashboard() ``` -## Custom Comparison with `are_props_equal` +## Custom Comparison Function -By default, `@ui.memo` uses shallow equality to compare props. You can provide a custom comparison function using the `are_props_equal` parameter: +By default, `memo=True` uses shallow equality to compare props. You can provide a custom comparison function by passing it directly to `memo`: ```python from deephaven import ui @@ -107,8 +106,7 @@ def compare_by_id(prev_props, next_props): return prev_props.get("id") == next_props.get("id") -@ui.memo(are_props_equal=compare_by_id) -@ui.component +@ui.component(memo=compare_by_id) def user_card(id, name, last_updated): return ui.flex( ui.text(f"User #{id}"), @@ -135,7 +133,7 @@ def user_profile(): user_profile_example = user_profile() ``` -The `are_props_equal` function receives two dictionaries: +The custom comparison function receives two dictionaries: - `prev_props`: The props from the previous render - `next_props`: The props for the current render @@ -159,8 +157,7 @@ def deep_equal(prev_props, next_props): ) -@ui.memo(are_props_equal=deep_equal) -@ui.component +@ui.component(memo=deep_equal) def data_display(config): return ui.text(f"Config: {config}") @@ -196,8 +193,7 @@ def significant_change(prev_props, next_props, threshold=5): return abs(next_value - prev_value) <= threshold -@ui.memo(are_props_equal=significant_change) -@ui.component +@ui.component(memo=significant_change) def progress_bar(value): return ui.progress_bar(value=value, label=f"{value}%") @@ -218,28 +214,25 @@ def app(): app_example = app() ``` -## Decorator Syntax +## Syntax Options -Both syntax forms are supported: +The `memo` parameter accepts different values: ```python -# Without parentheses (uses default shallow comparison) -@ui.memo +# Memoization disabled (default behavior) @ui.component def my_component(prop): return ui.text(prop) -# With parentheses (allows custom comparison) -@ui.memo() -@ui.component -def my_component_with_parens(prop): +# Memoization with shallow comparison +@ui.component(memo=True) +def my_memoized_component(prop): return ui.text(prop) -# With custom comparison function -@ui.memo(are_props_equal=my_custom_compare) -@ui.component +# Memoization with custom comparison function +@ui.component(memo=my_custom_compare) def my_component_custom(prop): return ui.text(prop) ``` @@ -254,8 +247,7 @@ When you pass a new object, list, or dictionary as a prop, it will always be a d from deephaven import ui -@ui.memo -@ui.component +@ui.component(memo=True) def item_list(items): return ui.flex(*[ui.text(item) for item in items], direction="column") @@ -290,8 +282,7 @@ Lambda functions and inline function definitions create new references each rend from deephaven import ui -@ui.memo -@ui.component +@ui.component(memo=True) def button_row(on_click): return ui.button("Click me", on_press=on_click) @@ -318,11 +309,11 @@ app_example = app() ## Comparison with `use_memo` -| Feature | `@ui.memo` | `use_memo` | +| Feature | `memo` parameter | `use_memo` | | ------- | ----------------------------- | ---------------------- | | Purpose | Skip re-rendering a component | Cache a computed value | -| Usage | Decorator on component | Hook inside component | +| Usage | Parameter on `@ui.component` | Hook inside component | | Input | Component props | Dependencies array | | Output | Memoized component | Memoized value | -Use `@ui.memo` to optimize component rendering. Use `use_memo` to optimize expensive calculations within a component. +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 f62fb40fa..08fde95d9 100644 --- a/plugins/ui/docs/add-interactivity/render-cycle.md +++ b/plugins/ui/docs/add-interactivity/render-cycle.md @@ -126,18 +126,17 @@ This works because during this last step, React only updates the content of `ui. After rendering is done and React updated the DOM, the browser will repaint the screen. -## Optimizing Re-renders with `@ui.memo` +## Optimizing Re-renders with `memo` -By default, when any component's state changes, `deephaven.ui` re-renders the entire component tree from the root—not just the component that triggered the change or its children, but every component in the tree. This is usually not a problem, but if you have a deeply nested tree or expensive components, you can optimize performance by wrapping components with `@ui.memo`. +By default, when any component's state changes, `deephaven.ui` re-renders the entire component tree from the root—not just the component that triggered the change or its children, but every component in the tree. This is usually not a problem, but if you have a deeply nested tree or expensive components, you can optimize performance by using the `memo` parameter on `@ui.component`. -The `@ui.memo` decorator tells `deephaven.ui` to skip re-rendering a component when its props haven't changed: +The `memo` parameter tells `deephaven.ui` to skip re-rendering a component when its props haven't changed: ```python from deephaven import ui -@ui.memo -@ui.component +@ui.component(memo=True) def expensive_child(value): # This component will only re-render when `value` changes return ui.text(f"Value: {value}") diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 5bbd11a33..2738bc638 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -50,7 +50,6 @@ from .logic_button import logic_button from .component import component from .markdown import markdown -from .memo import memo from .menu import menu from .menu_trigger import menu_trigger from .meter import meter @@ -139,7 +138,6 @@ "logic_button", "html", "markdown", - "memo", "menu", "menu_trigger", "meter", diff --git a/plugins/ui/src/deephaven/ui/components/component.py b/plugins/ui/src/deephaven/ui/components/component.py index 2c52b6777..23e993b0a 100644 --- a/plugins/ui/src/deephaven/ui/components/component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -1,25 +1,134 @@ 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, Union, overload +from .._internal import get_component_qualname, dict_shallow_equal +from ..elements import Element, FunctionElement, MemoizedElement, PropsType logger = logging.getLogger(__name__) -def component(func: Callable[..., Any]): +# Type alias for comparison functions +CompareFunction = Callable[[PropsType, PropsType], bool] + + +def _default_are_props_equal(prev_props: PropsType, next_props: PropsType) -> bool: + """ + The default are_props_equal function that does a shallow comparison of the props. + + Args: + prev_props: The previous props to check against the current props. + next_props: The current props to check against the previous props. + + Returns: + True if the props are equal, False otherwise. + """ + # Need to check the children separately, because they are passed in as a list and the list will be a different object each time even if the children are the same. + if "children" in prev_props and "children" in next_props: + prev_children = prev_props["children"] + next_children = next_props["children"] + + if not prev_children == next_children: + return False + + # Now we just need to do a dict_shallow_equal with all the other props that aren't children + return dict_shallow_equal( + {k: v for k, v in prev_props.items() if k != "children"}, + {k: v for k, v in next_props.items() if k != "children"}, + ) + + +@overload +def component(func: Callable[..., Any]) -> Callable[..., Element]: + """Basic usage without parentheses.""" + ... + + +@overload +def component( + func: None = None, + *, + memo: Union[bool, CompareFunction] = False, +) -> Callable[[Callable[..., Any]], Callable[..., Element]]: + """Usage with parameters.""" + ... + + +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/components/memo.py b/plugins/ui/src/deephaven/ui/components/memo.py deleted file mode 100644 index 8f889aba3..000000000 --- a/plugins/ui/src/deephaven/ui/components/memo.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations -import functools -import logging -from typing import Any, Callable - -from .._internal import dict_shallow_equal - -from ..elements import MemoizedElement, PropsType - -logger = logging.getLogger(__name__) - - -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. - """ - # Need to check the children separately, because they are passed in as a list and the list will be a different object each time even if the children are the same. - if "children" in prev_props and "children" in next_props: - prev_children = prev_props["children"] - next_children = next_props["children"] - - if not prev_children == next_children: - return False - - # Now we just need to do a dict_shallow_equal with all the other props that aren't children - return dict_shallow_equal( - {k: v for k, v in prev_props.items() if k != "children"}, - {k: v for k, v in next_props.items() if k != "children"}, - ) - - -def memo( - func_or_are_props_equal: ( - Callable[..., Any] | Callable[[PropsType, PropsType], bool] | None - ) = None, - *, - are_props_equal: Callable[[PropsType, PropsType], bool] | None = None, -): - """ - Create a MemoizedElement from the passed in function. - - A MemoizedElement is a component that will only re-render if its props have changed or if the context it is in is dirty (e.g. state has changed). - This can be used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. - - Can be used in several ways: - - 1. Without parentheses (uses default shallow comparison): - @ui.memo - @ui.component - def my_component(value): - return ui.text(str(value)) - - 2. With parentheses (uses default shallow comparison): - @ui.memo() - @ui.component - def my_component(value): - return ui.text(str(value)) - - 3. With custom comparison function: - @ui.memo(are_props_equal=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: - func_or_are_props_equal: Either the component function (when used without parentheses) - or None (when used with parentheses). - 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 not provided, uses shallow equality comparison. - """ - # Determine the actual comparison function to use - compare_fn = ( - are_props_equal if are_props_equal is not None else _default_are_props_equal - ) - - def memo_func( - func: Callable[..., Any], - ): - """ - Create a MemoizedElement from the passed in function. - - A MemoizedElement is a component that will only re-render if its props have changed or if the context it is in is dirty (e.g. state has changed). - This can be used to optimize performance by preventing unnecessary re-renders of components that are expensive to render. - - Args: - func: The function to create a MemoizedElement from. - Runs when the component is being rendered. - """ - - @functools.wraps(func) - def make_memoized_node(*args: Any, **kwargs: Any): - element = func(*args, **kwargs) - return MemoizedElement( - element, - {"children": [*args], **kwargs}, - compare_fn, - ) - - return make_memoized_node - - # Handle the different usage patterns - if func_or_are_props_equal is None: - # Called as @ui.memo() with optional are_props_equal kwarg - return memo_func - elif callable(func_or_are_props_equal): - # Called as @ui.memo without parentheses - func_or_are_props_equal is the decorated function - return memo_func(func_or_are_props_equal) - else: - raise TypeError( - f"memo() expected a callable or None, got {type(func_or_are_props_equal).__name__}" - ) diff --git a/plugins/ui/test/deephaven/ui/test_memo.py b/plugins/ui/test/deephaven/ui/test_memo.py index b37a319ca..180480118 100644 --- a/plugins/ui/test/deephaven/ui/test_memo.py +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -1,7 +1,7 @@ """ -Tests for component memoization (ui.memo decorator). +Tests for component memoization (memo parameter on @ui.component). -The @ui.memo decorator allows components to skip re-rendering when their props haven't +The memo parameter on @ui.component allows components to skip re-rendering when their props haven't changed, similar to React.memo(). """ @@ -18,7 +18,7 @@ class MemoTestCase(BaseTestCase): - """Tests for component memoization (@ui.memo decorator).""" + """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.""" @@ -41,15 +41,14 @@ 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 @ui.memo skips re-render when props are unchanged.""" + """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.memo - @ui.component + @ui.component(memo=True) def memoized_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -85,14 +84,13 @@ def parent(): 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.""" + """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.memo - @ui.component + @ui.component(memo=True) def memoized_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -123,14 +121,13 @@ def parent(): self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) def test_memo_rerenders_when_own_state_changes(self): - """Test that @ui.memo re-renders when the memoized component's own state changes.""" + """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.memo - @ui.component + @ui.component(memo=True) def memoized_child(value: int): child_render_count[0] += 1 internal_state, set_internal_state = ui.use_state(0) @@ -160,7 +157,7 @@ def parent(): self.assertEqual(child_render_count[0], 2) def test_memo_rerenders_when_both_props_and_state_change(self): - """Test that @ui.memo() re-renders when both props and internal state change.""" + """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) @@ -168,8 +165,7 @@ def test_memo_rerenders_when_both_props_and_state_change(self): button_ref = [None] parent_setter_ref = [None] - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_child(value: int): child_render_count[0] += 1 internal_state, set_internal_state = ui.use_state(0) @@ -202,15 +198,14 @@ def 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 @ui.memo() doesn't re-render when nothing changes (forced parent re-render).""" + """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.memo() - @ui.component + @ui.component(memo=True) def memoized_child(): child_render_count[0] += 1 return ui.text("Static content") @@ -255,8 +250,7 @@ def test_memo_with_custom_compare(self): def compare_only_value(prev, next): return prev.get("value") == next.get("value") - @ui.memo(are_props_equal=compare_only_value) - @ui.component + @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) @@ -302,8 +296,7 @@ def deep_equal_items(prev, next): next_items = next.get("children", [[]])[0] return prev_items == next_items # List equality compares contents - @ui.memo(are_props_equal=deep_equal_items) - @ui.component + @ui.component(memo=deep_equal_items) def child_with_list(items: list): child_render_count[0] += 1 return ui.text(str(items)) @@ -343,8 +336,7 @@ def test_memo_custom_compare_always_rerender(self): def always_different(prev, next): return False - @ui.memo(are_props_equal=always_different) - @ui.component + @ui.component(memo=always_different) def always_rerender_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -382,8 +374,7 @@ def test_memo_custom_compare_always_skip(self): def always_equal(prev, next): return True - @ui.memo(are_props_equal=always_equal) - @ui.component + @ui.component(memo=always_equal) def never_rerender_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -421,8 +412,7 @@ def test_memo_custom_compare_selective_props(self): def compare_important_only(prev, next): return prev.get("important_value") == next.get("important_value") - @ui.memo(are_props_equal=compare_important_only) - @ui.component + @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) @@ -468,8 +458,7 @@ def significant_change_only(prev, next): next_val = next.get("children", [[0]])[0] return abs(next_val - prev_val) <= 5 - @ui.memo(are_props_equal=significant_change_only) - @ui.component + @ui.component(memo=significant_change_only) def threshold_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -511,8 +500,7 @@ def test_memo_with_object_props_same_reference(self): child_render_count = [0] - @ui.memo() - @ui.component + @ui.component(memo=True) def child_with_list(items: list): child_render_count[0] += 1 return ui.text(str(len(items))) @@ -548,8 +536,7 @@ def test_memo_with_object_props_new_reference(self): child_render_count = [0] - @ui.memo() - @ui.component + @ui.component(memo=True) def child_with_list(items: list): child_render_count[0] += 1 return ui.text(str(len(items))) @@ -586,14 +573,12 @@ def test_memo_nested_components(self): parent_count = [0] child_count = [0] - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_child(value: int): child_count[0] += 1 return ui.text(f"Child: {value}") - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_parent(value: int): parent_count[0] += 1 return ui.flex( @@ -639,8 +624,7 @@ def test_memo_nested_with_internal_state(self): parent_count = [0] child_state_setter = [None] - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_parent(value: int): parent_count[0] += 1 child_state, set_child_state = ui.use_state("initial") @@ -695,8 +679,7 @@ def test_memo_with_multiple_props(self): child_render_count = [0] - @ui.memo() - @ui.component + @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}") @@ -729,8 +712,7 @@ def test_memo_with_one_prop_changed(self): child_render_count = [0] - @ui.memo() - @ui.component + @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}") @@ -764,8 +746,7 @@ def test_memo_with_children_prop(self): wrapper_render_count = [0] - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_wrapper(child_element): wrapper_render_count[0] += 1 return ui.view(child_element) @@ -802,8 +783,7 @@ def test_memo_with_none_props(self): child_render_count = [0] - @ui.memo() - @ui.component + @ui.component(memo=True) def memoized_child(value): child_render_count[0] += 1 return ui.text(f"Value: {value}") @@ -871,53 +851,39 @@ def parent(): # Non-memoized child should re-render even with same props self.assertEqual(child_render_count[0], 2) - def test_memo_both_syntaxes_work(self): - """Test that both @ui.memo and @ui.memo() syntaxes work identically.""" + 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) - no_parens_count = [0] - with_parens_count = [0] - - # Syntax without parentheses - @ui.memo - @ui.component - def memoized_no_parens(value: int): - no_parens_count[0] += 1 - return ui.text(f"No parens: {value}") + child_render_count = [0] - # Syntax with parentheses - @ui.memo() - @ui.component - def memoized_with_parens(value: int): - with_parens_count[0] += 1 - return ui.text(f"With parens: {value}") + @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)), - memoized_no_parens(value=42), - memoized_with_parens(value=42), + non_memoized_child(value=42), ) rc = RenderContext(on_change, on_queue) renderer = Renderer(rc) - # Initial render result = renderer.render(parent()) - self.assertEqual(no_parens_count[0], 1) - self.assertEqual(with_parens_count[0], 1) + self.assertEqual(child_render_count[0], 1) - # Trigger parent re-render with same props to children + # Trigger parent re-render button = self._find_action_button(result) button.props["onPress"](None) renderer.render(parent()) - # Both should skip re-render - self.assertEqual(no_parens_count[0], 1) - self.assertEqual(with_parens_count[0], 1) + # Should re-render because component is not memoized + self.assertEqual(child_render_count[0], 2) if __name__ == "__main__": From 881760dc54156b1141a813fea6864e358da19058 Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 14:59:27 -0500 Subject: [PATCH 08/19] Change up how children are compared - No special treatment for children --- .../src/deephaven/ui/components/component.py | 28 +------------------ plugins/ui/test/deephaven/ui/test_memo.py | 5 ++-- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/component.py b/plugins/ui/src/deephaven/ui/components/component.py index 23e993b0a..da895a308 100644 --- a/plugins/ui/src/deephaven/ui/components/component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -12,32 +12,6 @@ CompareFunction = Callable[[PropsType, PropsType], bool] -def _default_are_props_equal(prev_props: PropsType, next_props: PropsType) -> bool: - """ - The default are_props_equal function that does a shallow comparison of the props. - - Args: - prev_props: The previous props to check against the current props. - next_props: The current props to check against the previous props. - - Returns: - True if the props are equal, False otherwise. - """ - # Need to check the children separately, because they are passed in as a list and the list will be a different object each time even if the children are the same. - if "children" in prev_props and "children" in next_props: - prev_children = prev_props["children"] - next_children = next_props["children"] - - if not prev_children == next_children: - return False - - # Now we just need to do a dict_shallow_equal with all the other props that aren't children - return dict_shallow_equal( - {k: v for k, v in prev_props.items() if k != "children"}, - {k: v for k, v in next_props.items() if k != "children"}, - ) - - @overload def component(func: Callable[..., Any]) -> Callable[..., Element]: """Basic usage without parentheses.""" @@ -99,7 +73,7 @@ def my_component(value, on_click): compare_fn: CompareFunction | None = None elif memo is True: enable_memo = True - compare_fn = _default_are_props_equal + compare_fn = dict_shallow_equal elif callable(memo): enable_memo = True compare_fn = memo diff --git a/plugins/ui/test/deephaven/ui/test_memo.py b/plugins/ui/test/deephaven/ui/test_memo.py index 180480118..9602e0df8 100644 --- a/plugins/ui/test/deephaven/ui/test_memo.py +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -773,8 +773,9 @@ def parent(): button.props["onPress"](None) renderer.render(parent()) - # Should skip because the same stable_child object is passed - self.assertEqual(wrapper_render_count[0], 1) + # Should re-render because children are passed as positional args and new list is created each time + # This is how React.memo works with children - it does a shallow compare which sees a new list of children each time + self.assertEqual(wrapper_render_count[0], 2) def test_memo_with_none_props(self): """Test memoization handles None props correctly.""" From 2d868e8f4077f1ef7da7884485bb7150e2eb0ee2 Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 16:18:50 -0500 Subject: [PATCH 09/19] fix: Remove func param from second overload to fix Pylance error --- plugins/ui/src/deephaven/ui/components/component.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/component.py b/plugins/ui/src/deephaven/ui/components/component.py index da895a308..d8e5ab418 100644 --- a/plugins/ui/src/deephaven/ui/components/component.py +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -1,7 +1,7 @@ from __future__ import annotations import functools import logging -from typing import Any, Callable, Union, overload +from typing import Any, Callable, overload from .._internal import get_component_qualname, dict_shallow_equal from ..elements import Element, FunctionElement, MemoizedElement, PropsType @@ -14,17 +14,16 @@ @overload def component(func: Callable[..., Any]) -> Callable[..., Element]: - """Basic usage without parentheses.""" + """Basic usage without parentheses: @ui.component""" ... @overload def component( - func: None = None, *, - memo: Union[bool, CompareFunction] = False, + memo: bool | CompareFunction = ..., ) -> Callable[[Callable[..., Any]], Callable[..., Element]]: - """Usage with parameters.""" + """Usage with parameters: @ui.component() or @ui.component(memo=True)""" ... From fb3ee3b8e4d8de11ee4fdaed4ab997bcac2e33ec Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 17:16:14 -0500 Subject: [PATCH 10/19] Add plans for selective re-rendering, and tests that currently fail --- plans/component-memoization.md | 171 +++++++++++ plugins/ui/test/deephaven/ui/test_memo.py | 341 ++++++++++++++++++++++ 2 files changed, 512 insertions(+) diff --git a/plans/component-memoization.md b/plans/component-memoization.md index ddc1aa1fe..47cc1ba96 100644 --- a/plans/component-memoization.md +++ b/plans/component-memoization.md @@ -1254,6 +1254,177 @@ Only populated for MemoizedFunctionElement components. --- +## 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 diff --git a/plugins/ui/test/deephaven/ui/test_memo.py b/plugins/ui/test/deephaven/ui/test_memo.py index 9602e0df8..b2790419f 100644 --- a/plugins/ui/test/deephaven/ui/test_memo.py +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -886,6 +886,347 @@ def 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 From 00eb7e8cb36af64ede0ff68c1d1fdc01c2814aed Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 19:21:30 -0500 Subject: [PATCH 11/19] Selective re-rendering - Now it is optimized to only re-render when necessary - Needed to fix up some existing tests that was relying on the previous non-optimized behaviour - Added some unit tests --- .../deephaven/ui/_internal/RenderContext.py | 18 +- .../ui/src/deephaven/ui/renderer/Renderer.py | 158 +++++++++++++----- plugins/ui/test/deephaven/ui/test_memo.py | 19 ++- plugins/ui/test/deephaven/ui/test_renderer.py | 10 +- 4 files changed, 145 insertions(+), 60 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 7ffbce475..7e69d20d1 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -468,11 +468,27 @@ def update_state(): self._on_change(update_state) self._is_dirty = True - def get_child_context(self, key: ContextKey) -> "RenderContext": + def get_child_context( + self, key: ContextKey, fetch_only: bool = False + ) -> "RenderContext": """ Get the child context for the given key. + + Args: + key: The key of the child context to get. + fetch_only: If True, only return an existing context without creating + a new one or adding it to collected contexts. Raises KeyError if + the context doesn't exist. + + Returns: + The child context for the given key. + + Raises: + KeyError: If fetch_only is True and the context doesn't exist. """ logger.debug("Getting child context for key %s", key) + if fetch_only: + return self._children_context[key] if key not in self._children_context: child_context = RenderContext(self._on_change, self._on_queue_render) logger.debug( diff --git a/plugins/ui/src/deephaven/ui/renderer/Renderer.py b/plugins/ui/src/deephaven/ui/renderer/Renderer.py index 832c2b169..dbdec25fb 100644 --- a/plugins/ui/src/deephaven/ui/renderer/Renderer.py +++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py @@ -10,7 +10,12 @@ 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,23 +23,35 @@ def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) item: The item to render. parent_context: The context of the parent to render the item in. index_key: The key of the item in the parent context if it is a list or tuple. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree Returns: The rendered item. """ logger.debug("_render_child_item parent_context is %s", parent_context) + fetch_only = not is_dirty_render + if isinstance(item, (list, map, tuple)): - return _render_list(item, parent_context.get_child_context(index_key)) + return _render_list( + item, + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, + ) if isinstance(item, dict): - return _render_dict(item, parent_context.get_child_context(index_key)) + return _render_dict( + item, + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, + ) # If the item is an instance of a dataclass if is_dataclass(item) and not isinstance(item, type): return _render_dict( remove_empty_keys(dataclass_asdict(item)), - parent_context.get_child_context(index_key), + parent_context.get_child_context(index_key, fetch_only), + is_dirty_render, ) if isinstance(item, Element): @@ -44,14 +61,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. @@ -60,19 +83,45 @@ def _render_list( Args: item: The list to render. context: The context to render the list in. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. Returns: The rendered list. """ logger.debug("_render_list %s", item) + if not is_dirty_render: + # Don't open the context + return _render_list_in_open_context(item, context, is_dirty_render) + with context.open(): - return [ - _render_child_item(value, context, str(key)) - for key, value in enumerate(item) - ] + return _render_list_in_open_context(item, context, is_dirty_render) + + +def _render_list_in_open_context( + item: Union[list[Any], map[Any], tuple[Any, ...]], + context: RenderContext, + is_dirty_render: bool, +) -> list[Any]: + """ + Render a list. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. + For example, a `label` prop of a button can accept a string or an element. + + Args: + item: The list to render. + context: The context to render the list in. This context should already be open. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. + Returns: + The rendered list. + """ + return [ + _render_child_item(value, context, str(key), is_dirty_render) + for key, value in enumerate(item) + ] -def _render_dict(item: PropsType, context: RenderContext) -> PropsType: +def _render_dict( + item: PropsType, context: RenderContext, is_dirty_render: bool +) -> PropsType: """ Render a dictionary. You may be able to pass in an element as a prop that needs to be rendered, not just as a child. For example, a `label` prop of a button can accept a string or an element. @@ -80,17 +129,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. @@ -98,20 +154,27 @@ 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. @@ -124,47 +187,44 @@ def _render_element(element: Element, context: RenderContext) -> RenderedNode: 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 element_props is not None and context.cache is not None: - logger.debug( - "Element is a MemoizedElement, checking if it needs to be re-rendered" - ) + 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 - prev_props, prev_rendered_node = context.cache - - 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) - ): - logger.debug( - "MemoizedElement props are equal and context is not dirty, returning cached rendered node" - ) - return prev_rendered_node - - logger.debug( - "MemoizedElement props have changed or context is dirty, re-rendering element" + needs_render = is_dirty_render + + if isinstance(element, MemoizedElement): + needs_render = not element.are_props_equal(prev_props) + + if not needs_render and not context.is_dirty: + logger.debug("Returning cached element %s", element.name) + rendered_props = _render_dict_in_open_context( + prev_rendered_element_props, context, False ) + return RenderedNode(element.name, rendered_props) with context.open(): - props = element.render() - - # We also need to render any elements that are passed in as props (including `children`) - props = _render_dict_in_open_context(props, context) + logger.debug("Rendering element %s", element.name) - rendered_node = RenderedNode(element.name, props) + rendered_element_props = element.render() - logger.debug("Rendered element %s with input props %s", element.name, element_props) + context.cache = (element_props, rendered_element_props) - if isinstance(element, MemoizedElement) and element_props is not None: - context.cache = (element_props, rendered_node) + # We also need to render any elements that are passed in as props (including `children`) + rendered_props = _render_dict_in_open_context( + rendered_element_props, context, True + ) - return rendered_node + return RenderedNode(element.name, rendered_props) class Renderer: @@ -172,6 +232,16 @@ class Renderer: Renders Elements provided into the RenderContext provided and returns a RenderedNode. At this step it executing the render() method of the Element within the RenderContext state to generate the realized Document tree for the Element provided. + + There are a few things to note about the Renderer and RenderContext: + - The Renderer is responsible for rendering an Element and all of its children, but it does not manage the state of the Element, or liveness scopes, or hooks. Those are all tracked by the RenderContext. + - There is a RenderContext created for each Element. If that Element unmounts, that RenderContext will be destroyed. + - The RenderContext also has a cache that the Renderer uses to store the previous rendered result of an Element. + - State changes in an Element will mark the RenderContext as dirty, which will cause the Renderer to re-render the Element and its children. + - When an Element is dirty, it will be re-rendered along with all of it's children + - When a MemoizedElement is encountered, the Renderer will _only_ re-render if the props have changes (as determined by the MemoizedElement's are_props_equal function) or if the context of that Element is dirty, whether it's a dirty render or not (e.g. one of it's parent components was marked dirty) + + By following these rules, we can ensure that we are only re-rendering the parts of the tree that need to be re-rendered, and we can skip re-rendering for parts of the tree that have not changed, even in cases where their parent Element has changed. """ _context: RenderContext @@ -192,4 +262,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 index b2790419f..8992cb9e4 100644 --- a/plugins/ui/test/deephaven/ui/test_memo.py +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -451,6 +451,7 @@ def test_memo_custom_compare_with_threshold(self): 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): @@ -463,12 +464,12 @@ def threshold_child(value: int): child_render_count[0] += 1 return ui.text(f"Value: {value}") - value_ref = [0] - @ui.component def parent(): + value, set_value = ui.use_state(0) + parent_set_value[0] = set_value return ui.flex( - threshold_child(value_ref[0]), + threshold_child(value), ) rc = RenderContext(on_change, on_queue) @@ -479,17 +480,17 @@ def parent(): self.assertEqual(child_render_count[0], 1) # Small change (within threshold) - should skip - value_ref[0] = 3 + parent_set_value[0](3) renderer.render(parent()) self.assertEqual(child_render_count[0], 1) # Another small change - should skip - value_ref[0] = 5 + parent_set_value[0](5) renderer.render(parent()) self.assertEqual(child_render_count[0], 1) # Big change (exceeds threshold) - should re-render - value_ref[0] = 15 + parent_set_value[0](15) renderer.render(parent()) self.assertEqual(child_render_count[0], 2) @@ -653,9 +654,9 @@ def grandparent(): # Change state within memoized component (dirty tracking should work) child_state_setter[0]("updated") result = renderer.render(grandparent()) - # grandparent component function always runs when we render grandparent() + # grandparent component function should not re-run because it's own state didn't change self.assertEqual( - grandparent_count[0], 2 + 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) @@ -666,7 +667,7 @@ def grandparent(): result = renderer.render(grandparent()) self.assertEqual( - grandparent_count[0], 3 + grandparent_count[0], 2 ) # Grandparent re-rendered (state changed) self.assertEqual( parent_count[0], 2 diff --git a/plugins/ui/test/deephaven/ui/test_renderer.py b/plugins/ui/test/deephaven/ui/test_renderer.py index 19d138f6b..7d5016fe6 100644 --- a/plugins/ui/test/deephaven/ui/test_renderer.py +++ b/plugins/ui/test/deephaven/ui/test_renderer.py @@ -129,7 +129,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, [ @@ -137,8 +137,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() @@ -215,12 +213,12 @@ def test_render_child_item(self): rc = RenderContext(Mock(), Mock()) self.assertEqual( - _render_child_item({"key": "value"}, rc, "key"), + _render_child_item({"key": "value"}, rc, "key", True), {"key": "value"}, ) self.assertEqual( - _render_child_item([0, 1, 2], rc, "key"), + _render_child_item([0, 1, 2], rc, "key", True), [0, 1, 2], ) @@ -234,7 +232,7 @@ class MyDataclass: b: Element nested_dataclass = _render_child_item( - [MyDataclass("test", my_comp())], rc, "key" + [MyDataclass("test", my_comp())], rc, "key", True )[0] self.assertEqual( From c6b048cdcf36d1a740ace8988722dbb2779e52fc Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 23:24:06 -0500 Subject: [PATCH 12/19] Just change the example to require `list` - BREAKING CHANGE: No longer allowing GeneratorType to be returned as children - Doesn't really make sense anyways, as we consume it all immediately so no real savings over a `list`... - Should update typing to match --- tests/app.d/ui.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/app.d/ui.py b/tests/app.d/ui.py index 2ded56a04..80d69c2d4 100644 --- a/tests/app.d/ui.py +++ b/tests/app.d/ui.py @@ -63,18 +63,20 @@ def delete_cell(delete_id: int): set_cells(lambda old_cells: [c for c in old_cells if c != delete_id]) return ui.view( - map( - lambda i: ui.flex( - ui_cell(label=f"Cell {i}"), - ui.action_button( - ui.icon("trash"), - aria_label="Delete cell", - on_press=lambda _: delete_cell(i), + list( + map( + lambda i: ui.flex( + ui_cell(label=f"Cell {i}"), + ui.action_button( + ui.icon("trash"), + aria_label="Delete cell", + on_press=lambda _: delete_cell(i), + ), + align_items="end", + key=str(i), ), - align_items="end", - key=str(i), - ), - cells, + cells, + ) ), ui.action_button(ui.icon("add"), "Add cell", on_press=add_cell), overflow="auto", From 0241cc88787219d1472775a8a9bfa2482aa1d272 Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 12 Feb 2026 23:53:48 -0500 Subject: [PATCH 13/19] Fix up typing - Don't allow `GeneratorType` as a type for children - Now `children` is a more specific type --- .../deephaven/ui/components/action_button.py | 4 +- .../deephaven/ui/components/action_group.py | 4 +- .../ui/src/deephaven/ui/components/badge.py | 4 +- .../ui/src/deephaven/ui/components/basic.py | 27 ++++++- .../ui/src/deephaven/ui/components/button.py | 4 +- .../deephaven/ui/components/button_group.py | 4 +- .../src/deephaven/ui/components/checkbox.py | 4 +- .../deephaven/ui/components/checkbox_group.py | 4 +- .../deephaven/ui/components/color_picker.py | 4 +- .../ui/src/deephaven/ui/components/column.py | 4 +- .../ui/src/deephaven/ui/components/content.py | 4 +- .../ui/components/disclosure_panel.py | 4 +- .../ui/components/disclosure_title.py | 4 +- .../ui/src/deephaven/ui/components/flex.py | 4 +- .../ui/src/deephaven/ui/components/form.py | 4 +- .../src/deephaven/ui/components/fragment.py | 4 +- .../ui/src/deephaven/ui/components/grid.py | 4 +- .../ui/src/deephaven/ui/components/heading.py | 4 +- .../ui/src/deephaven/ui/components/html.py | 72 +++++++++---------- .../ui/components/illustrated_message.py | 4 +- .../ui/src/deephaven/ui/components/link.py | 4 +- .../deephaven/ui/components/logic_button.py | 4 +- .../ui/src/deephaven/ui/components/panel.py | 4 +- .../deephaven/ui/components/radio_group.py | 4 +- plugins/ui/src/deephaven/ui/components/row.py | 6 +- .../ui/src/deephaven/ui/components/stack.py | 4 +- .../ui/src/deephaven/ui/components/switch.py | 4 +- plugins/ui/src/deephaven/ui/components/tab.py | 4 +- .../src/deephaven/ui/components/tab_list.py | 3 +- .../src/deephaven/ui/components/tab_panels.py | 3 +- .../ui/src/deephaven/ui/components/tabs.py | 4 +- .../ui/src/deephaven/ui/components/text.py | 4 +- .../deephaven/ui/components/toggle_button.py | 4 +- .../ui/src/deephaven/ui/components/view.py | 4 +- .../src/deephaven/ui/elements/BaseElement.py | 9 +-- .../ui/src/deephaven/ui/elements/Element.py | 7 +- 36 files changed, 135 insertions(+), 108 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/action_button.py b/plugins/ui/src/deephaven/ui/components/action_button.py index e87669ebe..084e17d48 100644 --- a/plugins/ui/src/deephaven/ui/components/action_button.py +++ b/plugins/ui/src/deephaven/ui/components/action_button.py @@ -21,13 +21,13 @@ ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType ActionButtonElement = Element def action_button( - *children: Any, + *children: NodeType, type: ButtonType = "button", on_press: PressEventCallable | None = None, on_press_start: PressEventCallable | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/action_group.py b/plugins/ui/src/deephaven/ui/components/action_group.py index 4e7def18a..b639b38de 100644 --- a/plugins/ui/src/deephaven/ui/components/action_group.py +++ b/plugins/ui/src/deephaven/ui/components/action_group.py @@ -17,12 +17,12 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import ActionGroupDensity, SelectedKeys, SelectionMode, Key, Selection def action_group( - *children: Any, + *children: NodeType, is_emphasized: bool | None = None, density: ActionGroupDensity | None = "regular", is_justified: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/badge.py b/plugins/ui/src/deephaven/ui/components/badge.py index b20f09970..69a614893 100644 --- a/plugins/ui/src/deephaven/ui/components/badge.py +++ b/plugins/ui/src/deephaven/ui/components/badge.py @@ -10,12 +10,12 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import BadgeVariant def badge( - *children: Any, + *children: NodeType, variant: BadgeVariant | None = None, flex: LayoutFlex | None = None, flex_grow: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/basic.py b/plugins/ui/src/deephaven/ui/components/basic.py index b18ed8e15..507213fd4 100644 --- a/plugins/ui/src/deephaven/ui/components/basic.py +++ b/plugins/ui/src/deephaven/ui/components/basic.py @@ -1,14 +1,35 @@ from __future__ import annotations from typing import Any -from ..elements import BaseElement +from ..elements import BaseElement, NodeType +from ..types import Key NAME_PREFIX = "deephaven.ui.components." -def component_element(name: str, /, *children: Any, **props: Any) -> BaseElement: +def component_element( + name: str, + /, + *children: NodeType, + key: Key | None = None, + _nullable_props: list[str] = [], + **props: Any, +) -> BaseElement: """ Base class for UI elements. All names are automatically prefixed with "deephaven.ui.components.", and all props are automatically camelCased. + + Args: + name: The name of the element, e.g. "button", "input", "my_component", etc. The full name of the element will be "deephaven.ui.components.{name}". + children: The children of the element. + key: The key for the element. + _nullable_props: A list of prop names that can be set to None. + props: The props for the element. """ - return BaseElement(f"{NAME_PREFIX}{name}", *children, **props) + return BaseElement( + f"{NAME_PREFIX}{name}", + *children, + key=key, + _nullable_props=_nullable_props, + **props, + ) diff --git a/plugins/ui/src/deephaven/ui/components/button.py b/plugins/ui/src/deephaven/ui/components/button.py index 003ebb659..4bd305d54 100644 --- a/plugins/ui/src/deephaven/ui/components/button.py +++ b/plugins/ui/src/deephaven/ui/components/button.py @@ -22,11 +22,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def button( - *children: Any, + *children: NodeType, variant: ButtonVariant | None = "accent", style: ButtonStyle | None = "fill", static_color: StaticColor | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/button_group.py b/plugins/ui/src/deephaven/ui/components/button_group.py index edbd2a1bd..c77ba2099 100644 --- a/plugins/ui/src/deephaven/ui/components/button_group.py +++ b/plugins/ui/src/deephaven/ui/components/button_group.py @@ -13,13 +13,13 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType BUTTON_GROUP_NAME = "ButtonGroup" def button_group( - *children: Any, + *children: NodeType, is_disabled: bool | None = None, orientation: Orientation = "horizontal", align: ButtonGroupAlignment = "start", diff --git a/plugins/ui/src/deephaven/ui/components/checkbox.py b/plugins/ui/src/deephaven/ui/components/checkbox.py index 9450ffb87..544ac104a 100644 --- a/plugins/ui/src/deephaven/ui/components/checkbox.py +++ b/plugins/ui/src/deephaven/ui/components/checkbox.py @@ -14,11 +14,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def checkbox( - *children: Any, + *children: NodeType, is_emphasized: bool | None = None, is_indeterminate: bool | None = None, default_selected: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/checkbox_group.py b/plugins/ui/src/deephaven/ui/components/checkbox_group.py index 7996f14e4..bd1101fdc 100644 --- a/plugins/ui/src/deephaven/ui/components/checkbox_group.py +++ b/plugins/ui/src/deephaven/ui/components/checkbox_group.py @@ -16,12 +16,12 @@ LabelPosition, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import Key, Selection def checkbox_group( - *children: Any, + *children: NodeType, orientation: Orientation = "vertical", is_emphasized: bool | None = None, value: Selection | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/color_picker.py b/plugins/ui/src/deephaven/ui/components/color_picker.py index 98b230b74..bc0e1afb1 100644 --- a/plugins/ui/src/deephaven/ui/components/color_picker.py +++ b/plugins/ui/src/deephaven/ui/components/color_picker.py @@ -2,12 +2,12 @@ from typing import Any, Callable from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import CSSColor def color_picker( - *children: Any, + *children: NodeType, label: Any = None, size: str = "M", rounding: str = "default", diff --git a/plugins/ui/src/deephaven/ui/components/column.py b/plugins/ui/src/deephaven/ui/components/column.py index 85d9a981a..9c0dede49 100644 --- a/plugins/ui/src/deephaven/ui/components/column.py +++ b/plugins/ui/src/deephaven/ui/components/column.py @@ -2,11 +2,11 @@ from typing import Any from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def column( - *children: Any, width: float | None = None, key: str | None = None + *children: NodeType, width: float | None = None, key: str | None = None ) -> Element: """ A column is a container that can be used to group elements. diff --git a/plugins/ui/src/deephaven/ui/components/content.py b/plugins/ui/src/deephaven/ui/components/content.py index b6f1ec853..f683ea119 100644 --- a/plugins/ui/src/deephaven/ui/components/content.py +++ b/plugins/ui/src/deephaven/ui/components/content.py @@ -10,13 +10,13 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType CONTENT_NAME = "Content" def content( - *children: Any, + *children: NodeType, flex: LayoutFlex | None = None, flex_grow: float | None = None, flex_shrink: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/disclosure_panel.py b/plugins/ui/src/deephaven/ui/components/disclosure_panel.py index 83ff82e83..7958f0871 100644 --- a/plugins/ui/src/deephaven/ui/components/disclosure_panel.py +++ b/plugins/ui/src/deephaven/ui/components/disclosure_panel.py @@ -9,11 +9,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def disclosure_panel( - *children: Any, + *children: NodeType, flex: LayoutFlex | None = None, flex_grow: float | None = None, flex_shrink: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/disclosure_title.py b/plugins/ui/src/deephaven/ui/components/disclosure_title.py index 4cd55c5a2..c10affa0b 100644 --- a/plugins/ui/src/deephaven/ui/components/disclosure_title.py +++ b/plugins/ui/src/deephaven/ui/components/disclosure_title.py @@ -10,11 +10,11 @@ HeadingLevel, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def disclosure_title( - *children: Any, + *children: NodeType, level: HeadingLevel = 3, flex: LayoutFlex | None = None, flex_grow: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/flex.py b/plugins/ui/src/deephaven/ui/components/flex.py index 635085fb2..4e1054028 100644 --- a/plugins/ui/src/deephaven/ui/components/flex.py +++ b/plugins/ui/src/deephaven/ui/components/flex.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Any from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from .types import ( LayoutFlex, Direction, @@ -18,7 +18,7 @@ def flex( - *children: Any, + *children: NodeType, direction: Direction | None = None, wrap: Wrap | None = None, justify_content: JustifyContent | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/form.py b/plugins/ui/src/deephaven/ui/components/form.py index bca349bb6..58c0eae94 100644 --- a/plugins/ui/src/deephaven/ui/components/form.py +++ b/plugins/ui/src/deephaven/ui/components/form.py @@ -19,11 +19,11 @@ NecessityIndicator, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def form( - *children: Any, + *children: NodeType, is_quiet: bool | None = None, is_emphasized: bool | None = None, is_disabled: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/fragment.py b/plugins/ui/src/deephaven/ui/components/fragment.py index f604c6d11..a05afe659 100644 --- a/plugins/ui/src/deephaven/ui/components/fragment.py +++ b/plugins/ui/src/deephaven/ui/components/fragment.py @@ -2,10 +2,10 @@ from typing import Any from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType -def fragment(*children: Any, key: str | None = None) -> Element: +def fragment(*children: NodeType, key: str | None = None) -> Element: """ A React.Fragment: https://react.dev/reference/react/Fragment. Used to group elements together without a wrapper node. diff --git a/plugins/ui/src/deephaven/ui/components/grid.py b/plugins/ui/src/deephaven/ui/components/grid.py index 22142f4d1..95e80b28d 100644 --- a/plugins/ui/src/deephaven/ui/components/grid.py +++ b/plugins/ui/src/deephaven/ui/components/grid.py @@ -15,14 +15,14 @@ AlignItems, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import Undefined, UndefinedType _NULLABLE_PROPS = ["justify_content", "align_content", "align_items"] def grid( - *children: Any, + *children: NodeType, areas: list[str] | None = None, rows: str | list[DimensionValue] | None = None, columns: str | list[DimensionValue] | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/heading.py b/plugins/ui/src/deephaven/ui/components/heading.py index 6b3e7db24..fe2efbbb1 100644 --- a/plugins/ui/src/deephaven/ui/components/heading.py +++ b/plugins/ui/src/deephaven/ui/components/heading.py @@ -12,13 +12,13 @@ ) from ..types import Color from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType HEADING_NAME = "Heading" def heading( - *children: Any, + *children: NodeType, level: HeadingLevel = 3, color: Color | None = None, flex: LayoutFlex | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/html.py b/plugins/ui/src/deephaven/ui/components/html.py index 40e2d2f90..19046afe1 100644 --- a/plugins/ui/src/deephaven/ui/components/html.py +++ b/plugins/ui/src/deephaven/ui/components/html.py @@ -5,10 +5,10 @@ The components provided in deephaven.ui should be preferred over this module. """ -from ..elements import BaseElement +from ..elements import BaseElement, NodeType -def html_element(tag: str, *children: Any, **attributes: Any) -> BaseElement: +def html_element(tag: str, *children: NodeType, **attributes: Any) -> BaseElement: """ Create a new HTML element. Render just returns the children that are passed in. @@ -23,7 +23,7 @@ def html_element(tag: str, *children: Any, **attributes: Any) -> BaseElement: return BaseElement(f"deephaven.ui.html.{tag}", *children, **attributes) -def div(*children: Any, **attributes: Any) -> BaseElement: +def div(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "div" element with the specified children and attributes. @@ -37,7 +37,7 @@ def div(*children: Any, **attributes: Any) -> BaseElement: return html_element("div", *children, **attributes) -def span(*children: Any, **attributes: Any) -> BaseElement: +def span(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "span" element with the specified children and attributes. @@ -51,7 +51,7 @@ def span(*children: Any, **attributes: Any) -> BaseElement: return html_element("span", *children, **attributes) -def h1(*children: Any, **attributes: Any) -> BaseElement: +def h1(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h1" element with the specified children and attributes. @@ -65,7 +65,7 @@ def h1(*children: Any, **attributes: Any) -> BaseElement: return html_element("h1", *children, **attributes) -def h2(*children: Any, **attributes: Any) -> BaseElement: +def h2(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h2" element with the specified children and attributes. @@ -79,7 +79,7 @@ def h2(*children: Any, **attributes: Any) -> BaseElement: return html_element("h2", *children, **attributes) -def h3(*children: Any, **attributes: Any) -> BaseElement: +def h3(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h3" element with the specified children and attributes. @@ -93,7 +93,7 @@ def h3(*children: Any, **attributes: Any) -> BaseElement: return html_element("h3", *children, **attributes) -def h4(*children: Any, **attributes: Any) -> BaseElement: +def h4(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h4" element with the specified children and attributes. @@ -107,7 +107,7 @@ def h4(*children: Any, **attributes: Any) -> BaseElement: return html_element("h4", *children, **attributes) -def h5(*children: Any, **attributes: Any) -> BaseElement: +def h5(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h5" element with the specified children and attributes. @@ -121,7 +121,7 @@ def h5(*children: Any, **attributes: Any) -> BaseElement: return html_element("h5", *children, **attributes) -def h6(*children: Any, **attributes: Any) -> BaseElement: +def h6(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "h6" element with the specified children and attributes. @@ -135,7 +135,7 @@ def h6(*children: Any, **attributes: Any) -> BaseElement: return html_element("h6", *children, **attributes) -def p(*children: Any, **attributes: Any) -> BaseElement: +def p(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "p" element with the specified children and attributes. @@ -149,7 +149,7 @@ def p(*children: Any, **attributes: Any) -> BaseElement: return html_element("p", *children, **attributes) -def a(*children: Any, **attributes: Any) -> BaseElement: +def a(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "a" element with the specified children and attributes. @@ -163,7 +163,7 @@ def a(*children: Any, **attributes: Any) -> BaseElement: return html_element("a", *children, **attributes) -def ul(*children: Any, **attributes: Any) -> BaseElement: +def ul(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "ul" element with the specified children and attributes. @@ -177,7 +177,7 @@ def ul(*children: Any, **attributes: Any) -> BaseElement: return html_element("ul", *children, **attributes) -def ol(*children: Any, **attributes: Any) -> BaseElement: +def ol(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "ol" element with the specified children and attributes. @@ -191,7 +191,7 @@ def ol(*children: Any, **attributes: Any) -> BaseElement: return html_element("ol", *children, **attributes) -def li(*children: Any, **attributes: Any) -> BaseElement: +def li(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "li" element with the specified children and attributes. @@ -205,7 +205,7 @@ def li(*children: Any, **attributes: Any) -> BaseElement: return html_element("li", *children, **attributes) -def table(*children: Any, **attributes: Any) -> BaseElement: +def table(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "table" element with the specified children and attributes. @@ -219,7 +219,7 @@ def table(*children: Any, **attributes: Any) -> BaseElement: return html_element("table", *children, **attributes) -def thead(*children: Any, **attributes: Any) -> BaseElement: +def thead(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "thead" element with the specified children and attributes. @@ -233,7 +233,7 @@ def thead(*children: Any, **attributes: Any) -> BaseElement: return html_element("thead", *children, **attributes) -def tbody(*children: Any, **attributes: Any) -> BaseElement: +def tbody(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "tbody" element with the specified children and attributes. @@ -247,7 +247,7 @@ def tbody(*children: Any, **attributes: Any) -> BaseElement: return html_element("tbody", *children, **attributes) -def tr(*children: Any, **attributes: Any) -> BaseElement: +def tr(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "tr" element with the specified children and attributes. @@ -261,7 +261,7 @@ def tr(*children: Any, **attributes: Any) -> BaseElement: return html_element("tr", *children, **attributes) -def th(*children: Any, **attributes: Any) -> BaseElement: +def th(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "th" element with the specified children and attributes. @@ -275,7 +275,7 @@ def th(*children: Any, **attributes: Any) -> BaseElement: return html_element("th", *children, **attributes) -def td(*children: Any, **attributes: Any) -> BaseElement: +def td(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "td" element with the specified children and attributes. @@ -289,7 +289,7 @@ def td(*children: Any, **attributes: Any) -> BaseElement: return html_element("td", *children, **attributes) -def b(*children: Any, **attributes: Any) -> BaseElement: +def b(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "b" element with the specified children and attributes. @@ -303,7 +303,7 @@ def b(*children: Any, **attributes: Any) -> BaseElement: return html_element("b", *children, **attributes) -def i(*children: Any, **attributes: Any) -> BaseElement: +def i(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "i" element with the specified children and attributes. @@ -317,7 +317,7 @@ def i(*children: Any, **attributes: Any) -> BaseElement: return html_element("i", *children, **attributes) -def br(*children: Any, **attributes: Any) -> BaseElement: +def br(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "br" element with the specified children and attributes. @@ -331,7 +331,7 @@ def br(*children: Any, **attributes: Any) -> BaseElement: return html_element("br", *children, **attributes) -def hr(*children: Any, **attributes: Any) -> BaseElement: +def hr(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "hr" element with the specified children and attributes. @@ -345,7 +345,7 @@ def hr(*children: Any, **attributes: Any) -> BaseElement: return html_element("hr", *children, **attributes) -def pre(*children: Any, **attributes: Any) -> BaseElement: +def pre(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "pre" element with the specified children and attributes. @@ -359,7 +359,7 @@ def pre(*children: Any, **attributes: Any) -> BaseElement: return html_element("pre", *children, **attributes) -def code(*children: Any, **attributes: Any) -> BaseElement: +def code(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "code" element with the specified children and attributes. @@ -373,7 +373,7 @@ def code(*children: Any, **attributes: Any) -> BaseElement: return html_element("code", *children, **attributes) -def img(*children: Any, **attributes: Any) -> BaseElement: +def img(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "img" element with the specified children and attributes. @@ -387,7 +387,7 @@ def img(*children: Any, **attributes: Any) -> BaseElement: return html_element("img", *children, **attributes) -def button(*children: Any, **attributes: Any) -> BaseElement: +def button(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "button" element with the specified children and attributes. @@ -401,7 +401,7 @@ def button(*children: Any, **attributes: Any) -> BaseElement: return html_element("button", *children, **attributes) -def input(*children: Any, **attributes: Any) -> BaseElement: +def input(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "input" element with the specified children and attributes. @@ -415,7 +415,7 @@ def input(*children: Any, **attributes: Any) -> BaseElement: return html_element("input", *children, **attributes) -def form(*children: Any, **attributes: Any) -> BaseElement: +def form(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "form" element with the specified children and attributes. @@ -429,7 +429,7 @@ def form(*children: Any, **attributes: Any) -> BaseElement: return html_element("form", *children, **attributes) -def label(*children: Any, **attributes: Any) -> BaseElement: +def label(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "label" element with the specified children and attributes. @@ -443,7 +443,7 @@ def label(*children: Any, **attributes: Any) -> BaseElement: return html_element("label", *children, **attributes) -def select(*children: Any, **attributes: Any) -> BaseElement: +def select(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "select" element with the specified children and attributes. @@ -457,7 +457,7 @@ def select(*children: Any, **attributes: Any) -> BaseElement: return html_element("select", *children, **attributes) -def option(*children: Any, **attributes: Any) -> BaseElement: +def option(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "option" element with the specified children and attributes. @@ -471,7 +471,7 @@ def option(*children: Any, **attributes: Any) -> BaseElement: return html_element("option", *children, **attributes) -def textarea(*children: Any, **attributes: Any) -> BaseElement: +def textarea(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "textarea" element with the specified children and attributes. @@ -485,7 +485,7 @@ def textarea(*children: Any, **attributes: Any) -> BaseElement: return html_element("textarea", *children, **attributes) -def style(*children: Any, **attributes: Any) -> BaseElement: +def style(*children: NodeType, **attributes: Any) -> BaseElement: """ Creates an HTML "style" element with the specified children and attributes. diff --git a/plugins/ui/src/deephaven/ui/components/illustrated_message.py b/plugins/ui/src/deephaven/ui/components/illustrated_message.py index 4e284c6d7..b512ebf59 100644 --- a/plugins/ui/src/deephaven/ui/components/illustrated_message.py +++ b/plugins/ui/src/deephaven/ui/components/illustrated_message.py @@ -9,11 +9,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def illustrated_message( - *children: Any, + *children: NodeType, flex: LayoutFlex | None = None, flex_grow: float | None = None, flex_shrink: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/link.py b/plugins/ui/src/deephaven/ui/components/link.py index a4c4cef26..cb37fa34e 100644 --- a/plugins/ui/src/deephaven/ui/components/link.py +++ b/plugins/ui/src/deephaven/ui/components/link.py @@ -13,12 +13,12 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import LinkVariant def link( - *children: Any, + *children: NodeType, variant: LinkVariant | None = "primary", is_quiet: bool | None = None, auto_focus: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/logic_button.py b/plugins/ui/src/deephaven/ui/components/logic_button.py index d836bdd0e..f5b81dd14 100644 --- a/plugins/ui/src/deephaven/ui/components/logic_button.py +++ b/plugins/ui/src/deephaven/ui/components/logic_button.py @@ -20,11 +20,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def logic_button( - *children: Any, + *children: NodeType, variant: str | None = None, is_disabled: bool | None = None, auto_focus: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/panel.py b/plugins/ui/src/deephaven/ui/components/panel.py index dca1e4af4..c3da7abfb 100644 --- a/plugins/ui/src/deephaven/ui/components/panel.py +++ b/plugins/ui/src/deephaven/ui/components/panel.py @@ -13,12 +13,12 @@ Overflow, CSSProperties, ) -from ..elements import Element +from ..elements import Element, NodeType from ..types import Color def panel( - *children: Any, + *children: NodeType, title: str | None = None, direction: Direction | None = "column", wrap: Wrap | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/radio_group.py b/plugins/ui/src/deephaven/ui/components/radio_group.py index 4477f6f23..7c3c207e6 100644 --- a/plugins/ui/src/deephaven/ui/components/radio_group.py +++ b/plugins/ui/src/deephaven/ui/components/radio_group.py @@ -18,7 +18,7 @@ ValidationBehavior, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import Undefined, UndefinedType from .._internal.utils import create_props @@ -27,7 +27,7 @@ def radio_group( - *children: Any, + *children: NodeType, is_emphasized: bool | None = None, orientation: Orientation = "vertical", value: str | None | UndefinedType = Undefined, diff --git a/plugins/ui/src/deephaven/ui/components/row.py b/plugins/ui/src/deephaven/ui/components/row.py index d059beae1..c0cafd6df 100644 --- a/plugins/ui/src/deephaven/ui/components/row.py +++ b/plugins/ui/src/deephaven/ui/components/row.py @@ -2,10 +2,12 @@ from typing import Any from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType -def row(*children: Any, height: float | None = None, key: str | None = None) -> Element: +def row( + *children: NodeType, height: float | None = None, key: str | None = None +) -> Element: """ A row is a container that can be used to group elements. Each element will be placed to the right of its prior sibling. diff --git a/plugins/ui/src/deephaven/ui/components/stack.py b/plugins/ui/src/deephaven/ui/components/stack.py index 52470f035..036a7b592 100644 --- a/plugins/ui/src/deephaven/ui/components/stack.py +++ b/plugins/ui/src/deephaven/ui/components/stack.py @@ -2,11 +2,11 @@ from typing import Any from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def stack( - *children: Any, + *children: NodeType, height: float | None = None, width: float | None = None, active_item_index: int | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/switch.py b/plugins/ui/src/deephaven/ui/components/switch.py index ff5642a50..e3da2a6b4 100644 --- a/plugins/ui/src/deephaven/ui/components/switch.py +++ b/plugins/ui/src/deephaven/ui/components/switch.py @@ -11,11 +11,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def switch( - *children: Any, + *children: NodeType, is_emphasized: bool | None = None, default_selected: bool | None = None, is_selected: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/tab.py b/plugins/ui/src/deephaven/ui/components/tab.py index abaf467b1..e61961043 100644 --- a/plugins/ui/src/deephaven/ui/components/tab.py +++ b/plugins/ui/src/deephaven/ui/components/tab.py @@ -3,13 +3,13 @@ from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import Key def tab( - *children: Any, + *children: NodeType, title: Any | None = None, key: Key | None = None, icon: Element | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/tab_list.py b/plugins/ui/src/deephaven/ui/components/tab_list.py index 941611a33..d9291d026 100644 --- a/plugins/ui/src/deephaven/ui/components/tab_list.py +++ b/plugins/ui/src/deephaven/ui/components/tab_list.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +from ..elements import NodeType from .basic import component_element from .types import ( @@ -14,7 +15,7 @@ def tab_list( - *children: Any, + *children: NodeType, flex: LayoutFlex | None = None, flex_grow: float | None = None, flex_shrink: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/tab_panels.py b/plugins/ui/src/deephaven/ui/components/tab_panels.py index e6b80a1e5..f6f4133ab 100644 --- a/plugins/ui/src/deephaven/ui/components/tab_panels.py +++ b/plugins/ui/src/deephaven/ui/components/tab_panels.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +from ..elements import NodeType from .basic import component_element from .types import ( @@ -14,7 +15,7 @@ def tab_panels( - *children: Any, + *children: NodeType, flex: LayoutFlex | None = None, flex_grow: float | None = None, flex_shrink: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/tabs.py b/plugins/ui/src/deephaven/ui/components/tabs.py index 354db8d48..2f6ffe8c9 100644 --- a/plugins/ui/src/deephaven/ui/components/tabs.py +++ b/plugins/ui/src/deephaven/ui/components/tabs.py @@ -15,7 +15,7 @@ ) from ..types import Key, TabDensity, Undefined, UndefinedType -from ..elements import BaseElement +from ..elements import BaseElement, Element TabElement = BaseElement @@ -24,7 +24,7 @@ def tabs( - *children: Any, + *children: Element, disabled_keys: Iterable[Key] | None = None, is_disabled: bool | None = None, is_quiet: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/text.py b/plugins/ui/src/deephaven/ui/components/text.py index 43bce3b0a..14b79da60 100644 --- a/plugins/ui/src/deephaven/ui/components/text.py +++ b/plugins/ui/src/deephaven/ui/components/text.py @@ -10,11 +10,11 @@ ) from ..types import Color from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def text( - *children: Any, + *children: NodeType, color: Color | None = None, flex: LayoutFlex | None = None, flex_grow: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/toggle_button.py b/plugins/ui/src/deephaven/ui/components/toggle_button.py index d9f1cafc2..70d3a0052 100644 --- a/plugins/ui/src/deephaven/ui/components/toggle_button.py +++ b/plugins/ui/src/deephaven/ui/components/toggle_button.py @@ -20,11 +20,11 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType def toggle_button( - *children: Any, + *children: NodeType, is_emphasized: bool | None = None, is_selected: bool | None = None, default_selected: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/view.py b/plugins/ui/src/deephaven/ui/components/view.py index 0511048d5..9033de78a 100644 --- a/plugins/ui/src/deephaven/ui/components/view.py +++ b/plugins/ui/src/deephaven/ui/components/view.py @@ -12,12 +12,12 @@ Position, ) from .basic import component_element -from ..elements import Element +from ..elements import Element, NodeType from ..types import Color def view( - *children: Any, + *children: NodeType, element_type: ElementTypes | None = None, flex: LayoutFlex | None = None, flex_grow: float | None = None, diff --git a/plugins/ui/src/deephaven/ui/elements/BaseElement.py b/plugins/ui/src/deephaven/ui/elements/BaseElement.py index 1c3fcc03e..c95c57dc8 100644 --- a/plugins/ui/src/deephaven/ui/elements/BaseElement.py +++ b/plugins/ui/src/deephaven/ui/elements/BaseElement.py @@ -1,8 +1,9 @@ from __future__ import annotations from typing import Any -from .Element import Element +from .Element import Element, NodeType from .._internal import dict_to_react_props, RenderContext +from ..types import Key class BaseElement(Element): @@ -22,8 +23,8 @@ def __init__( self, name: str, /, - *children: Any, - key: str | None = None, + *children: NodeType, + key: Key | None = None, _nullable_props: list[str] = [], **props: Any, ): @@ -47,7 +48,7 @@ def name(self) -> str: return self._name @property - def key(self) -> str | None: + def key(self) -> Key | None: return self._key def render(self) -> dict[str, Any]: diff --git a/plugins/ui/src/deephaven/ui/elements/Element.py b/plugins/ui/src/deephaven/ui/elements/Element.py index 6caf216b5..745e9b280 100644 --- a/plugins/ui/src/deephaven/ui/elements/Element.py +++ b/plugins/ui/src/deephaven/ui/elements/Element.py @@ -1,8 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Dict, List, Union -from .._internal import RenderContext +from typing import Any, Dict, List, Tuple, Union PropsType = Dict[str, Any] @@ -45,4 +44,6 @@ def render(self) -> PropsType: # Some props don't support Undefined, so they need to add it themselves -NodeType = Union[None, bool, int, str, Element, List["NodeType"]] +NodeType = Union[ + None, bool, int, str, Element, List["NodeType"], Tuple["NodeType", ...] +] From fa74c9541cd09bf3265163569d1ceb2c2c9f5052 Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 13 Feb 2026 08:34:43 -0500 Subject: [PATCH 14/19] Need to mark the RenderContext as dirty when importing state - Otherwise we get errors when reloading widgets --- plugins/ui/src/deephaven/ui/_internal/RenderContext.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 7e69d20d1..052bd62b4 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -593,6 +593,8 @@ def import_state(self, state: dict[str, Any]) -> None: """ self._state.clear() self._children_context.clear() + self._is_dirty = True + if "state" in state: for key, value in state["state"].items(): # When python dict is converted to JSON, all keys are converted to strings. We convert them back to int here. From 04fd6d1fcaf4fbff6351ef326c22f022b2e2f4cc Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Fri, 13 Feb 2026 10:21:00 -0500 Subject: [PATCH 15/19] Update docs snapshots --- plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json | 1 + plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json | 1 + plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json | 1 + plugins/ui/docs/snapshots/649f7ec080e8937474ffc4fe90bfde3d.json | 1 + plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json | 1 + plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json | 1 + plugins/ui/docs/snapshots/e2ff6d692f17f9ed9305c25a3c6fea28.json | 1 + plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json | 1 + 8 files changed, 8 insertions(+) create mode 100644 plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json create mode 100644 plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json create mode 100644 plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json create mode 100644 plugins/ui/docs/snapshots/649f7ec080e8937474ffc4fe90bfde3d.json create mode 100644 plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json create mode 100644 plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json create mode 100644 plugins/ui/docs/snapshots/e2ff6d692f17f9ed9305c25a3c6fea28.json create mode 100644 plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json diff --git a/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json b/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json new file mode 100644 index 000000000..f42ae3e5f --- /dev/null +++ b/plugins/ui/docs/snapshots/223e056fae1e8cc660d6084b4784f2b0.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"dashboard_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Update"}},{"__dhElemName":"__main__.live_counter","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}}}},{"__dhElemName":"__main__.expensive_chart","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Chart with 5 points"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.dashboard"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json b/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json new file mode 100644 index 000000000..d611dd4bc --- /dev/null +++ b/plugins/ui/docs/snapshots/22c1e3a013e6a776b2c33e48d0d067e6.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}},{"__dhElemName":"__main__.greeting","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Hello, World!"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}},":log":{"type":"Log","data":"Rendering greeting for World\n"}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json b/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json new file mode 100644 index 000000000..dba9c3dd3 --- /dev/null +++ b/plugins/ui/docs/snapshots/5672d3207b0dce60fc3ae5dd631dad16.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"__main__.data_display","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Config: {'setting': 'value', 'enabled': True}"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/649f7ec080e8937474ffc4fe90bfde3d.json b/plugins/ui/docs/snapshots/649f7ec080e8937474ffc4fe90bfde3d.json new file mode 100644 index 000000000..21b6f0e74 --- /dev/null +++ b/plugins/ui/docs/snapshots/649f7ec080e8937474ffc4fe90bfde3d.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/7f5367fb55911f4082b4bd2d6587245a.json b/plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json new file mode 100644 index 000000000..755a30372 --- /dev/null +++ b/plugins/ui/docs/snapshots/7f5367fb55911f4082b4bd2d6587245a.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"app_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"+1"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"+10"}},{"__dhElemName":"__main__.progress_bar","props":{"children":{"__dhElemName":"deephaven.ui.components.ProgressBar","props":{"size":"L","labelPosition":"top","label":"0%","value":0,"minValue":0,"maxValue":100}}}}]}}},"__dhElemName":"__main__.app"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json b/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json new file mode 100644 index 000000000..2dc88198b --- /dev/null +++ b/plugins/ui/docs/snapshots/a76f40f8cab7652d92c2112ff43ca309.json @@ -0,0 +1 @@ +{"file":"add-interactivity/render-cycle.md","objects":{"parent_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Increment"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}},{"__dhElemName":"__main__.expensive_child","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Value: hello"],"slot":"text"}}}}]}}},"__dhElemName":"__main__.parent"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/e2ff6d692f17f9ed9305c25a3c6fea28.json b/plugins/ui/docs/snapshots/e2ff6d692f17f9ed9305c25a3c6fea28.json new file mode 100644 index 000000000..3ecffb814 --- /dev/null +++ b/plugins/ui/docs/snapshots/e2ff6d692f17f9ed9305c25a3c6fea28.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/f38b10993d650f63b27d1e584c3307f7.json b/plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json new file mode 100644 index 000000000..3163c77be --- /dev/null +++ b/plugins/ui/docs/snapshots/f38b10993d650f63b27d1e584c3307f7.json @@ -0,0 +1 @@ +{"file":"add-interactivity/memoizing-components.md","objects":{"user_profile_example":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Update timestamp"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Change name"}},{"__dhElemName":"__main__.user_card","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["User #1"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Name: Alice"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Updated: 12:00"],"slot":"text"}}]}}}}]}}},"__dhElemName":"__main__.user_profile"},"state":"{\"state\": {\"0\": \"Alice\", \"1\": \"12:00\"}}"}}}} \ No newline at end of file From b5927316ba06403e288fd23200ec8531802164ac Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 13 Feb 2026 10:41:19 -0500 Subject: [PATCH 16/19] Fix up typing of NodeType and PropsType - Changes PropsType to a Mapping instead of a Dict. Allows TypedDicts to be passed in as well then, and we don't need to modify props so it's more accurate - Add `float` to NodeType - Added a note to update a few spots where we pass a Table or ItemTableSource back directly, rather than wrapping them in an Element or something else --- plugins/ui/src/deephaven/ui/_internal/utils.py | 16 ++++++++++++++-- .../ui/src/deephaven/ui/components/combo_box.py | 3 ++- .../ui/src/deephaven/ui/components/list_view.py | 3 ++- plugins/ui/src/deephaven/ui/components/picker.py | 3 ++- plugins/ui/src/deephaven/ui/elements/Element.py | 6 +++--- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index c28b2fc1e..f1f63631a 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -1,6 +1,18 @@ 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, @@ -952,7 +964,7 @@ def is_iterable(value: Any) -> bool: return isinstance(value, (list, tuple, set, dict, map, filter, range)) -def dict_shallow_equal(dict1: dict[str, Any], dict2: dict[str, Any]) -> bool: +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. diff --git a/plugins/ui/src/deephaven/ui/components/combo_box.py b/plugins/ui/src/deephaven/ui/components/combo_box.py index 58b3da335..59acf727c 100644 --- a/plugins/ui/src/deephaven/ui/components/combo_box.py +++ b/plugins/ui/src/deephaven/ui/components/combo_box.py @@ -244,6 +244,7 @@ def combo_box( children, props = unpack_item_table_source(children, props, SUPPORTED_SOURCE_ARGS) + # TODO: These "children" can be Table or ItemTableSource, which aren't actually valid children from a React point of view. They should be passed in as elements. return component_element( - "ComboBox", *children, _nullable_props=_NULLABLE_PROPS, **props + "ComboBox", *children, _nullable_props=_NULLABLE_PROPS, **props # type: ignore ) diff --git a/plugins/ui/src/deephaven/ui/components/list_view.py b/plugins/ui/src/deephaven/ui/components/list_view.py index e3c213a03..b2674f981 100644 --- a/plugins/ui/src/deephaven/ui/components/list_view.py +++ b/plugins/ui/src/deephaven/ui/components/list_view.py @@ -182,4 +182,5 @@ def list_view( children, props = unpack_item_table_source(children, props, SUPPORTED_SOURCE_ARGS) - return component_element("ListView", *children, **props) + # TODO: These "children" can be Table or ItemTableSource, which aren't actually valid children from a React point of view. They should be passed in as elements. + return component_element("ListView", *children, **props) # type: ignore diff --git a/plugins/ui/src/deephaven/ui/components/picker.py b/plugins/ui/src/deephaven/ui/components/picker.py index bb43e8346..4dcb8dae4 100644 --- a/plugins/ui/src/deephaven/ui/components/picker.py +++ b/plugins/ui/src/deephaven/ui/components/picker.py @@ -225,6 +225,7 @@ def picker( children, props = unpack_item_table_source(children, props, SUPPORTED_SOURCE_ARGS) + # TODO: These "children" can be Table or ItemTableSource, which aren't actually valid children from a React point of view. They should be passed in as elements. return component_element( - "Picker", *children, _nullable_props=_NULLABLE_PROPS, **props + "Picker", *children, _nullable_props=_NULLABLE_PROPS, **props # type: ignore ) diff --git a/plugins/ui/src/deephaven/ui/elements/Element.py b/plugins/ui/src/deephaven/ui/elements/Element.py index 745e9b280..61fc5599b 100644 --- a/plugins/ui/src/deephaven/ui/elements/Element.py +++ b/plugins/ui/src/deephaven/ui/elements/Element.py @@ -1,9 +1,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Dict, List, Tuple, Union +from typing import Any, List, Mapping, Tuple, Union -PropsType = Dict[str, Any] +PropsType = Mapping[str, Any] class Element(ABC): @@ -45,5 +45,5 @@ def render(self) -> PropsType: # Some props don't support Undefined, so they need to add it themselves NodeType = Union[ - None, bool, int, str, Element, List["NodeType"], Tuple["NodeType", ...] + None, bool, float, int, str, Element, List["NodeType"], Tuple["NodeType", ...] ] From ee1bdbc98051603757279d8c136b0eb47b074c16 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Fri, 13 Feb 2026 14:37:43 -0500 Subject: [PATCH 17/19] Skip a test --- plugins/ui/docs/add-interactivity/memoizing-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md index 241c772c0..459940dfe 100644 --- a/plugins/ui/docs/add-interactivity/memoizing-components.md +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -218,7 +218,7 @@ app_example = app() The `memo` parameter accepts different values: -```python +```python skip-test # Memoization disabled (default behavior) @ui.component def my_component(prop): From 91805c16360efd74e3bd1e237959484e2dbcf509 Mon Sep 17 00:00:00 2001 From: mikebender Date: Wed, 18 Feb 2026 10:31:53 -0500 Subject: [PATCH 18/19] Cleanup based on review --- plugins/ui/docs/add-interactivity/memoizing-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md index 459940dfe..14c719629 100644 --- a/plugins/ui/docs/add-interactivity/memoizing-components.md +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -292,7 +292,7 @@ def app(): count, set_count = ui.use_state(0) # BAD: Creates a new function reference every render - # handle_click_bad = lambda: print("clicked") + handle_click_bad = lambda: print("clicked") # GOOD: Use use_callback to memoize the function handle_click_good = ui.use_callback(lambda: print("clicked"), []) From 56cf4287010dbbacf6b34a45b223097339a1dc3b Mon Sep 17 00:00:00 2001 From: mikebender Date: Wed, 18 Feb 2026 10:35:58 -0500 Subject: [PATCH 19/19] Bit more consistency --- plugins/ui/docs/add-interactivity/memoizing-components.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md index 14c719629..cbc3e42a9 100644 --- a/plugins/ui/docs/add-interactivity/memoizing-components.md +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -256,11 +256,11 @@ def item_list(items): def app(): count, set_count = ui.use_state(0) - # BAD: Creates a new list on every render + # ❌ Creates a new list on every render # item_list will re-render every time even though content is the same items_bad = ["apple", "banana"] - # GOOD: Use use_memo to keep the same reference + # ✅ Use use_memo to keep the same reference items_good = ui.use_memo(lambda: ["apple", "banana"], []) return ui.flex( @@ -291,10 +291,10 @@ def button_row(on_click): def app(): count, set_count = ui.use_state(0) - # BAD: Creates a new function reference every render + # ❌ Creates a new function reference every render handle_click_bad = lambda: print("clicked") - # GOOD: Use use_callback to memoize the function + # ✅ Use use_callback to memoize the function handle_click_good = ui.use_callback(lambda: print("clicked"), []) return ui.flex(