diff --git a/plans/component-memoization.md b/plans/component-memoization.md new file mode 100644 index 000000000..47cc1ba96 --- /dev/null +++ b/plans/component-memoization.md @@ -0,0 +1,1436 @@ +# Component Memoization Plan: Props-Based Re-render Skip + +## Overview + +This plan describes implementing props-based memoization for `deephaven.ui` components, allowing child components to skip re-rendering when their props haven't changed — even when their parent re-renders. This is similar to React's `React.memo()` functionality. + +**Current behavior:** When a parent component re-renders, all children re-render (unless they have dirty descendant optimization, which only helps when state changes are deep in the tree, not when the parent itself re-renders). + +**Proposed behavior:** Memoized components compare their props; if unchanged, they return cached output and skip re-rendering. + +--- + +## Option A: Separate `@ui.memo` Decorator + +### API Design + +```python +@ui.memo +@ui.component +def my_component(value: int, label: str): + """This component will skip re-rendering if value and label are unchanged.""" + return ui.text(f"{label}: {value}") +``` + +The `@ui.memo` decorator wraps a component (created by `@ui.component`) to add props comparison. + +### Implementation + +#### A1. Create `memo.py` + +```python +# plugins/ui/src/deephaven/ui/components/memo.py + +from __future__ import annotations +import functools +from typing import Any, Callable, Optional +from ..elements import MemoizedFunctionElement, FunctionElement + + +def memo( + component_or_compare: Callable[..., Any] + | Callable[[dict, dict], bool] + | None = None, + *, + compare: Callable[[dict, dict], bool] | None = None, +): + """ + Memoize a component to skip re-rendering when props are unchanged. + + Can be used in several ways: + + 1. Basic usage (shallow comparison): + @ui.memo + @ui.component + def my_component(value): + return ui.text(str(value)) + + 2. With custom comparison function: + @ui.memo(compare=lambda prev, next: prev["value"] == next["value"]) + @ui.component + def my_component(value, on_click): + return ui.button(str(value), on_press=on_click) + + Args: + component_or_compare: Either the component function (when used without parentheses), + or a comparison function (deprecated usage). + compare: Custom comparison function that receives (prev_props, next_props) + and returns True if they are equal (should skip re-render). + If None, uses shallow equality comparison. + + Returns: + A memoized component that skips re-rendering when props are unchanged. + """ + + def create_memo_wrapper(component: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(component) + def memo_wrapper(*args: Any, key: str | None = None, **kwargs: Any): + # Get the FunctionElement from the component + element = component(*args, key=key, **kwargs) + + if not isinstance(element, FunctionElement): + raise TypeError( + f"@ui.memo can only be used with @ui.component decorated functions. " + f"Got {type(element).__name__} instead." + ) + + # Wrap in MemoizedFunctionElement which tracks props for comparison + return MemoizedFunctionElement( + element, + props={"args": args, "kwargs": kwargs}, + compare=compare, + ) + + return memo_wrapper + + # Handle both @ui.memo and @ui.memo() and @ui.memo(compare=...) + if component_or_compare is None: + # Called as @ui.memo() or @ui.memo(compare=...) + return create_memo_wrapper + elif callable(component_or_compare) and compare is None: + # Check if it looks like a comparison function (takes 2 args) or component + import inspect + + sig = inspect.signature(component_or_compare) + params = list(sig.parameters.values()) + + # Heuristic: comparison functions have exactly 2 positional params + if len(params) == 2 and all( + p.default == inspect.Parameter.empty for p in params + ): + # Ambiguous - could be compare function or 2-arg component + # Assume it's used as @ui.memo (without parens) wrapping a component + pass + + # Called as @ui.memo (without parentheses) + return create_memo_wrapper(component_or_compare) + else: + raise TypeError("Invalid usage of @ui.memo") +``` + +#### A2. Create `MemoizedFunctionElement` + +```python +# Add to plugins/ui/src/deephaven/ui/elements/MemoizedFunctionElement.py + +from __future__ import annotations +from typing import Any, Callable, Optional +from .FunctionElement import FunctionElement +from .Element import PropsType +from .._internal import RenderContext + + +def _shallow_equal(prev: dict, next: dict) -> bool: + """Check if two prop dictionaries are shallowly equal.""" + if prev.keys() != next.keys(): + return False + for key in prev: + if prev[key] is not next[key]: + return False + return True + + +class MemoizedFunctionElement(FunctionElement): + """ + A FunctionElement wrapper that memoizes based on props comparison. + + When rendered, compares current props with previously-cached props. + If props are equal (via shallow comparison or custom compare function), + returns cached output without re-executing the render function. + """ + + def __init__( + self, + wrapped_element: FunctionElement, + props: dict[str, Any], + compare: Callable[[dict, dict], bool] | None = None, + ): + """ + Create a memoized function element. + + Args: + wrapped_element: The FunctionElement to wrap. + props: The props passed to this component (args + kwargs). + compare: Custom comparison function, or None for shallow equality. + """ + super().__init__( + wrapped_element.name, + wrapped_element._render, + key=wrapped_element.key, + ) + self._props_for_memo = props + self._compare = compare or _shallow_equal + + @property + def props_for_memo(self) -> dict[str, Any]: + """The props to use for memoization comparison.""" + return self._props_for_memo + + @property + def compare_fn(self) -> Callable[[dict, dict], bool]: + """The comparison function for props.""" + return self._compare +``` + +#### A3. Modify Renderer to check memoized props + +Update `_render_element` in `Renderer.py`: + +```python +def _render_element(element: Element, context: RenderContext) -> RenderedNode: + """Render an Element, potentially reusing cached output for clean components.""" + logger.debug("Rendering element %s in context %s", element.name, context) + + is_function_element = isinstance(element, FunctionElement) + is_memoized = isinstance(element, MemoizedFunctionElement) + + # Check if we can skip rendering this component + if is_function_element and context._cached_rendered_node is not None: + # Memoized component: check props comparison + if is_memoized and context._cached_props_for_memo is not None: + if element.compare_fn( + context._cached_props_for_memo, element.props_for_memo + ): + # Props are equal - skip re-render entirely + logger.debug( + "Skipping memoized render for %s - props unchanged", element.name + ) + return context._cached_rendered_node + + # Existing dirty-tracking optimization + if not context._is_dirty: + if not context._has_dirty_descendant: + logger.debug("Skipping render for %s - using cached node", element.name) + return context._cached_rendered_node + else: + logger.debug("Re-rendering children only for %s", element.name) + return _render_children_only(context) + + # Full re-render needed + # ... existing code ... + + # After render, cache memoization props if applicable + if is_memoized: + context._cached_props_for_memo = element.props_for_memo +``` + +### Examples + +```python +import deephaven.ui as ui + +# Example 1: Basic memoization +@ui.memo +@ui.component +def expensive_chart(data: list[float], title: str): + """Only re-renders when data or title change.""" + # Expensive computation here + return ui.view(ui.heading(title), ui.text(f"Sum: {sum(data)}")) + + +# Example 2: Memoize with custom comparison (ignore callback props) +@ui.memo(compare=lambda prev, next: prev["kwargs"]["value"] == next["kwargs"]["value"]) +@ui.component +def counter_display(value: int, on_increment): + """Re-renders only when value changes, ignores callback changes.""" + return ui.flex( + ui.text(f"Count: {value}"), ui.button("Increment", on_press=on_increment) + ) + + +# Example 3: Parent that causes child re-renders +@ui.component +def parent(): + count, set_count = ui.use_state(0) + items, set_items = ui.use_state(["a", "b", "c"]) + + # Without @ui.memo, expensive_chart would re-render when count changes + # With @ui.memo, it only re-renders when items change + return ui.flex( + ui.button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), + expensive_chart(items, "My Chart"), + ) +``` + +--- + +## Option B: Parameter on `@ui.component` + +### API Design + +```python +@ui.component(memo=True) +def my_component(value: int, label: str): + """This component will skip re-rendering if value and label are unchanged.""" + return ui.text(f"{label}: {value}") + + +# Or with custom comparison function: +@ui.component(memo=lambda prev, next: prev["value"] == next["value"]) +def my_component(value: int, on_click): + return ui.button(str(value), on_press=on_click) +``` + +The `memo` parameter accepts: + +- `True`: Enable memoization with shallow equality comparison (default behavior) +- A callable `(prev_props, next_props) -> bool`: Custom comparison function that returns `True` if props are equal (should skip re-render) + +### Implementation + +#### B1. Modify `make_component.py` + +```python +# plugins/ui/src/deephaven/ui/components/make_component.py + +from __future__ import annotations +import functools +import logging +from typing import Any, Callable, Optional, Union, overload +from .._internal import get_component_qualname +from ..elements import FunctionElement, MemoizedFunctionElement + +logger = logging.getLogger(__name__) + + +def _shallow_equal(prev: dict, next: dict) -> bool: + """Check if two prop dictionaries are shallowly equal.""" + if prev.keys() != next.keys(): + return False + for key in prev: + if prev[key] is not next[key]: + return False + return True + + +# Type alias for comparison functions +CompareFunction = Callable[[dict, dict], bool] + + +@overload +def make_component(func: Callable[..., Any]) -> Callable[..., FunctionElement]: + """Basic usage without parentheses.""" + ... + + +@overload +def make_component( + func: None = None, + *, + memo: Union[bool, CompareFunction] = False, +) -> Callable[[Callable[..., Any]], Callable[..., FunctionElement]]: + """Usage with parameters.""" + ... + + +def make_component( + func: Callable[..., Any] | None = None, + *, + memo: bool | CompareFunction = False, +): + """ + Create a FunctionalElement from the passed in function. + + Args: + func: The function to create a FunctionalElement from. + Runs when the component is being rendered. + memo: Enable memoization to skip re-rendering when props are unchanged. + - False (default): No memoization, component always re-renders with parent. + - True: Enable memoization with shallow equality comparison. + - Callable: Custom comparison function (prev_props, next_props) -> bool + that returns True if props are equal (should skip re-render). + """ + # Determine if memoization is enabled and what comparison function to use + if memo is False: + enable_memo = False + compare_fn = None + elif memo is True: + enable_memo = True + compare_fn = _shallow_equal + elif callable(memo): + enable_memo = True + compare_fn = memo + else: + raise TypeError( + f"memo must be True, False, or a callable, got {type(memo).__name__}" + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., FunctionElement]: + @functools.wraps(fn) + def make_component_node(*args: Any, key: str | None = None, **kwargs: Any): + component_type = get_component_qualname(fn) + + if enable_memo: + element = FunctionElement( + component_type, lambda: fn(*args, **kwargs), key=key + ) + return MemoizedFunctionElement( + element, + props={"args": args, "kwargs": kwargs}, + compare=compare_fn, + ) + else: + return FunctionElement( + component_type, lambda: fn(*args, **kwargs), key=key + ) + + return make_component_node + + if func is not None: + # Called without parentheses: @ui.component + return decorator(func) + else: + # Called with parentheses: @ui.component() or @ui.component(memo=True) + return decorator +``` + +### Examples + +```python +import deephaven.ui as ui + +# Example 1: Basic memoized component (shallow comparison) +@ui.component(memo=True) +def expensive_chart(data: list[float], title: str): + """Only re-renders when data or title change.""" + return ui.view(ui.heading(title), ui.text(f"Sum: {sum(data)}")) + + +# Example 2: Memoized component with custom comparison function +@ui.component( + memo=lambda prev, next: prev["kwargs"]["value"] == next["kwargs"]["value"] +) +def counter_display(value: int, on_increment): + """Re-renders only when value changes, ignores callback changes.""" + return ui.flex( + ui.text(f"Count: {value}"), ui.button("Increment", on_press=on_increment) + ) + + +# Example 3: Mixed usage - non-memoized parent with memoized child +@ui.component +def parent(): + count, set_count = ui.use_state(0) + items, set_items = ui.use_state(["a", "b", "c"]) + + return ui.flex( + ui.button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), + expensive_chart(items, "My Chart"), # Won't re-render when count changes + ) + + +# Example 4: Using ui.use_callback for stable callback references +@ui.component +def parent_with_callbacks(): + count, set_count = ui.use_state(0) + + # Stable callback reference + handle_increment = ui.use_callback(lambda _: set_count(count + 1), [count]) + + return ui.flex( + ui.text(f"Parent count: {count}"), + counter_display(value=count, on_increment=handle_increment), + ) +``` + +--- + +## Comparison of Options + +| Aspect | Option A: `@ui.memo` | Option B: `memo=` param | +| --------------------------- | ---------------------------------------------- | ----------------------------------------- | +| **Similarity to React** | Very similar (`React.memo()`) | Similar naming, integrated into decorator | +| **Explicitness** | Clear separation of concerns | Single decorator, less visual clutter | +| **Discoverability** | Users familiar with React will look for `memo` | `memo` param is intuitive | +| **Flexibility** | Can wrap third-party components | Only works at definition time | +| **Code Readability** | Two decorators can be verbose | Single decorator is cleaner | +| **Backwards Compatibility** | Fully compatible (new API) | Fully compatible (optional parameter) | +| **Custom Comparison** | `@ui.memo(compare=...)` | `@ui.component(memo=compare_fn)` | +| **Decorator Order** | Must be `@ui.memo` then `@ui.component` | N/A | + +### Pros & Cons + +#### Option A: `@ui.memo` + +**Pros:** + +- ✅ Familiar to React developers +- ✅ Can potentially wrap existing components (third-party or legacy) +- ✅ Clear semantic: "this component is memoized" +- ✅ Separation of concerns: component definition vs optimization + +**Cons:** + +- ❌ Two decorators required (more verbose) +- ❌ Easy to get decorator order wrong (`@ui.component` then `@ui.memo` won't work) +- ❌ Slightly more complex implementation (need to wrap FunctionElement) + +#### Option B: `memo=` Parameter + +**Pros:** + +- ✅ Single decorator, cleaner syntax +- ✅ Impossible to get wrong (no decorator ordering) +- ✅ Uses same `memo` terminology as React, making intent clear +- ✅ All component config in one place +- ✅ Single param for both enabling and custom comparison (`memo=True` or `memo=fn`) + +**Cons:** + +- ❌ Cannot memoize third-party components +- ❌ Slightly less explicit than a separate decorator + +--- + +## Recommendation + +**Implement both options**, with Option B (`memo=`) as the primary API and Option A (`@ui.memo`) for advanced use cases. + +### Rationale: + +1. **Option B is simpler for common cases**: Most users just want to optimize their own components. A single decorator with `memo=True` is cleaner and less error-prone. + +2. **Option A enables advanced patterns**: Being able to wrap existing components is valuable for: + + - Optimizing third-party components + - Applying memoization conditionally + - Migrating codebases incrementally + +3. **Both share implementation**: The `MemoizedFunctionElement` and comparison logic are shared, so supporting both is low additional cost. + +### Suggested Default: + +For `@ui.component(memo=True)`, use **shallow equality comparison** by default. This matches React's `React.memo()` behavior and works well when: + +- Props are primitives (int, str, float, bool) +- Props are the same object references (e.g., from `use_state`, `use_memo`) + +For callbacks, recommend using `ui.use_callback()` (if not already available, implement it) to create stable references. + +--- + +## Implementation Plan + +### Phase 1: Core Infrastructure + +| Task | File | Effort | +| ----------------------------------------------- | ------------------------------------- | ------ | +| Create `MemoizedFunctionElement` class | `elements/MemoizedFunctionElement.py` | Low | +| Add `_cached_props_for_memo` to `RenderContext` | `_internal/RenderContext.py` | Low | +| Update `_render_element` for memoization check | `renderer/Renderer.py` | Medium | +| Implement `_shallow_equal` utility | `_internal/utils.py` | Low | + +### Phase 2: Option B - `memo` Parameter + +| Task | File | Effort | +| ----------------------------------------- | ------------------------------ | ------ | +| Update `make_component` with `memo` param | `components/make_component.py` | Medium | +| Update type hints and docstrings | `components/make_component.py` | Low | +| Export from `__init__.py` | `components/__init__.py` | Low | + +### Phase 3: Option A - `@ui.memo` Decorator + +| Task | File | Effort | +| ------------------------------------------- | ------------------------ | ------ | +| Create `memo.py` with decorator | `components/memo.py` | Medium | +| Export `memo` from `components/__init__.py` | `components/__init__.py` | Low | + +### Phase 4: Testing + +| Task | Effort | +| -------------------------------------------------- | ------ | +| Unit tests for `_shallow_equal` | Low | +| Unit tests for memoization skipping re-render | Medium | +| Unit tests for custom comparison function | Medium | +| Unit tests showing props change triggers re-render | Medium | +| Integration tests with nested memoized components | Medium | +| Tests for decorator order error handling | Low | + +### Phase 5: Performance Benchmarks + +| Task | Effort | +| --------------------------------------------- | ------ | +| Benchmark: large list with memoized items | Medium | +| Benchmark: deep tree with memoized branches | Medium | +| Compare memoized vs non-memoized render times | Low | + +--- + +## Unit Tests + +### Test File: `test_memoization.py` + +```python +from __future__ import annotations +from unittest.mock import Mock +from typing import Any, Callable, List +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoizationTestCase(BaseTestCase): + """Tests for component memoization (props-based re-render skip).""" + + def test_memo_skips_rerender_with_same_props(self): + """Test that @ui.memo skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.memo + @ui.component + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + # Pass same value to child regardless of parent state + return ui.flex( + ui.button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (change parent state) + # Find the button and click it + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_rerenders_when_props_change(self): + """Test that @ui.memo re-renders when props change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_value_ref = [None] + + @ui.memo + @ui.component + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + set_value_ref[0] = set_value + return memoized_child(value=value) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change the prop value + set_value_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) + + def test_memo_param_skips_rerender(self): + """Test that @ui.component(memo=True) skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + return ui.flex( + ui.button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_param_with_custom_compare(self): + """Test that @ui.component(memo=compare_fn) uses custom comparison.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_callback_ref = [None] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev["kwargs"]["value"] == next["kwargs"]["value"] + + @ui.component(memo=compare_only_value) + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + set_callback_ref[0] = set_count + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return child_with_callback(value=42, on_click=callback) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback) + set_callback_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (value unchanged) + + def test_memo_with_custom_compare(self): + """Test that custom compare function controls memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + set_callback_ref = [None] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev["kwargs"]["value"] == next["kwargs"]["value"] + + @ui.memo(compare=compare_only_value) + @ui.component + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + set_callback_ref[0] = set_count + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return child_with_callback(value=42, on_click=callback) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback) + set_callback_ref[0](1) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (value unchanged) + + # Now actually change value via parent mechanism + # This would require changing the prop value, which we're not doing here + # So child should remain at render count 1 + + def test_memo_with_object_props(self): + """Test memoization behavior with object props (reference equality).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.memo + @ui.component + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + # Same list object each time + shared_list = [1, 2, 3] + + @ui.component + def parent_with_shared_list(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=shared_list), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent_with_shared_list()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + renderer.render(parent_with_shared_list()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (same list reference) + + def test_memo_with_new_object_props(self): + """Test that memoization re-renders with new object references.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.memo + @ui.component + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + @ui.component + def parent_with_new_list(): + state, set_state = ui.use_state(0) + # Creates new list object each render + items = [1, 2, 3] + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent_with_new_list()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + renderer.render(parent_with_new_list()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (new list reference) + + def test_memo_integration_with_dirty_tracking(self): + """Test that memoization works correctly with existing dirty tracking.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_count = [0] + set_child_state_ref = [None] + + @ui.memo + @ui.component + def memoized_parent(value: int): + parent_count[0] += 1 + child_state, set_child_state = ui.use_state("initial") + set_child_state_ref[0] = set_child_state + return ui.text(f"{value}: {child_state}") + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.button(str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1)), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + + # Change state within memoized component (dirty tracking should work) + set_child_state_ref[0]("updated") + renderer.render(grandparent()) + self.assertEqual( + grandparent_count[0], 1 + ) # Grandparent clean (has dirty descendant) + self.assertEqual(parent_count[0], 2) # Parent re-rendered (own state dirty) + + # Now trigger grandparent re-render with same props to memoized_parent + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 2) # Grandparent re-rendered + self.assertEqual(parent_count[0], 2) # Parent SKIPPED (props unchanged) + + def _find_node(self, root, name): + """Helper to find a node by name in the rendered tree.""" + from deephaven.ui.renderer.RenderedNode import RenderedNode + + if root.name == name: + return root + children = root.props.get("children", []) if root.props else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") + + +class ShallowEqualTestCase(BaseTestCase): + """Tests for shallow equality comparison function.""" + + def test_equal_primitives(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertTrue( + shallow_equal( + {"a": 1, "b": "hello", "c": True}, {"a": 1, "b": "hello", "c": True} + ) + ) + + def test_different_primitives(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse(shallow_equal({"a": 1}, {"a": 2})) + + def test_same_object_reference(self): + from deephaven.ui._internal.utils import shallow_equal + + obj = [1, 2, 3] + self.assertTrue(shallow_equal({"items": obj}, {"items": obj})) + + def test_different_object_reference(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse( + shallow_equal( + {"items": [1, 2, 3]}, + {"items": [1, 2, 3]}, # Same content, different object + ) + ) + + def test_different_keys(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertFalse(shallow_equal({"a": 1}, {"a": 1, "b": 2})) + + def test_none_values(self): + from deephaven.ui._internal.utils import shallow_equal + + self.assertTrue(shallow_equal({"a": None}, {"a": None})) + self.assertFalse(shallow_equal({"a": None}, {"a": 0})) +``` + +--- + +## Performance Benchmarks + +### Test File: `test_memoization_benchmarks.py` + +```python +from __future__ import annotations +import time +from unittest.mock import Mock +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoizationBenchmarkTestCase(BaseTestCase): + """Performance benchmarks for component memoization.""" + + def test_benchmark_large_list_without_memo(self): + """Benchmark: re-rendering a large list of non-memoized components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + render_counts = {} + + @ui.component + def list_item(item_id: int): + render_counts[item_id] = render_counts.get(item_id, 0) + 1 + return ui.text(f"Item {item_id}") + + @ui.component + def list_container(): + state, set_state = ui.use_state(0) + items = list(range(100)) # 100 items + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + *[list_item(i, key=str(i)) for i in items], + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + start = time.perf_counter() + result = renderer.render(list_container()) + initial_time = time.perf_counter() - start + + # Verify all rendered + total_renders = sum(render_counts.values()) + self.assertEqual(total_renders, 100) + render_counts.clear() + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + start = time.perf_counter() + renderer.render(list_container()) + rerender_time = time.perf_counter() - start + + # All items re-render (no memoization) + total_rerenders = sum(render_counts.values()) + self.assertEqual(total_rerenders, 100) + + print( + f"\n[No Memo] Initial: {initial_time*1000:.2f}ms, Re-render: {rerender_time*1000:.2f}ms" + ) + print(f"[No Memo] Items re-rendered: {total_rerenders}/100") + + def test_benchmark_large_list_with_memo(self): + """Benchmark: re-rendering a large list of memoized components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + render_counts = {} + + @ui.memo + @ui.component + def memoized_list_item(item_id: int): + render_counts[item_id] = render_counts.get(item_id, 0) + 1 + return ui.text(f"Item {item_id}") + + @ui.component + def list_container(): + state, set_state = ui.use_state(0) + items = list(range(100)) # 100 items + return ui.flex( + ui.button(str(state), on_press=lambda _: set_state(state + 1)), + *[memoized_list_item(i, key=str(i)) for i in items], + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + start = time.perf_counter() + result = renderer.render(list_container()) + initial_time = time.perf_counter() - start + + # Verify all rendered + total_renders = sum(render_counts.values()) + self.assertEqual(total_renders, 100) + render_counts.clear() + + # Trigger re-render + button = self._find_node(result, "deephaven.ui.components.Button") + button.props["onPress"](None) + + start = time.perf_counter() + renderer.render(list_container()) + rerender_time = time.perf_counter() - start + + # NO items should re-render (memoized, props unchanged) + total_rerenders = sum(render_counts.values()) + self.assertEqual(total_rerenders, 0) + + print( + f"\n[With Memo] Initial: {initial_time*1000:.2f}ms, Re-render: {rerender_time*1000:.2f}ms" + ) + print(f"[With Memo] Items re-rendered: {total_rerenders}/100") + + def test_benchmark_deep_tree_memo(self): + """Benchmark: memoized components in deep tree with sibling state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + expensive_render_count = [0] + set_sibling_state_ref = [None] + + @ui.memo + @ui.component + def expensive_component(data: list): + expensive_render_count[0] += 1 + # Simulate expensive computation + total = sum(data) + return ui.text(f"Total: {total}") + + @ui.component + def sibling_with_state(): + state, set_state = ui.use_state(0) + set_sibling_state_ref[0] = set_state + return ui.text(f"Sibling: {state}") + + @ui.component + def parent(): + # Shared data that doesn't change + shared_data = ui.use_memo(lambda: list(range(1000)), []) + return ui.flex( + sibling_with_state(), + expensive_component(data=shared_data), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(expensive_render_count[0], 1) + + # Change sibling state multiple times + for i in range(10): + set_sibling_state_ref[0](i + 1) + renderer.render(parent()) + + # Expensive component should NOT have re-rendered + self.assertEqual(expensive_render_count[0], 1) + print( + f"\n[Deep Tree Memo] Expensive component renders after 10 sibling updates: {expensive_render_count[0]}" + ) + + def test_benchmark_memo_speedup_measurement(self): + """Measure actual speedup from memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + ITERATIONS = 100 + + # Test without memo + @ui.component + def child_no_memo(value: int): + return ui.text(f"Value: {value}") + + @ui.component + def parent_no_memo(): + state, set_state = ui.use_state(0) + return ui.flex(*[child_no_memo(i, key=str(i)) for i in range(50)]) + + rc1 = RenderContext(on_change, on_queue) + renderer1 = Renderer(rc1) + + # Warm up + renderer1.render(parent_no_memo()) + + start = time.perf_counter() + for _ in range(ITERATIONS): + renderer1.render(parent_no_memo()) + no_memo_time = time.perf_counter() - start + + # Test with memo + @ui.memo + @ui.component + def child_with_memo(value: int): + return ui.text(f"Value: {value}") + + @ui.component + def parent_with_memo(): + state, set_state = ui.use_state(0) + return ui.flex(*[child_with_memo(i, key=str(i)) for i in range(50)]) + + rc2 = RenderContext(on_change, on_queue) + renderer2 = Renderer(rc2) + + # Warm up + renderer2.render(parent_with_memo()) + + start = time.perf_counter() + for _ in range(ITERATIONS): + renderer2.render(parent_with_memo()) + memo_time = time.perf_counter() - start + + speedup = no_memo_time / memo_time if memo_time > 0 else float("inf") + + print(f"\n[Speedup Benchmark] {ITERATIONS} iterations, 50 children each") + print( + f" Without memo: {no_memo_time*1000:.2f}ms total ({no_memo_time*1000/ITERATIONS:.3f}ms per render)" + ) + print( + f" With memo: {memo_time*1000:.2f}ms total ({memo_time*1000/ITERATIONS:.3f}ms per render)" + ) + print(f" Speedup: {speedup:.1f}x faster") + + # Assert meaningful speedup (at least 2x) + self.assertGreater( + speedup, 2.0, "Memoization should provide at least 2x speedup" + ) + + def _find_node(self, root, name): + """Helper to find a node by name in the rendered tree.""" + from deephaven.ui.renderer.RenderedNode import RenderedNode + + if root.name == name: + return root + children = root.props.get("children", []) if root.props else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") +``` + +--- + +## RenderContext Updates + +Add to `RenderContext.__init__`: + +```python +self._cached_props_for_memo: Optional[dict[str, Any]] = None +""" +Cached props used for memoization comparison. +Only populated for MemoizedFunctionElement components. +""" +``` + +--- + +## Edge Cases to Handle + +1. **First render**: No cached props, must always render +2. **Key changes**: New key = new context = no cached props +3. **Component unmount/remount**: Cached props cleared when context is deleted +4. **Mixed memoized/non-memoized siblings**: Each maintains own state +5. **Nested memoized components**: Each level checks independently +6. **Custom compare returning wrong type**: Should raise or coerce to bool + +--- + +## Selective Re-rendering Scenarios + +A key challenge with memoization is ensuring that child components with internal state can still re-render when their state changes, even when their memoized parent is skipped. This requires selective re-rendering: the ability to re-render specific subtrees without re-rendering ancestor components. + +### Problem Statement + +When a memoized component's props haven't changed, the renderer returns the cached rendered node. However, if a child component within that memoized component has dirty state (state that changed), the child needs to re-render. The current implementation does not handle this case - the child never re-renders because the memoized parent short-circuits the entire subtree. + +**Bug identified**: `test_memo_child_with_internal_state` demonstrates this issue - when a memoized parent is skipped, child components with dirty state do not re-render. + +### Test Scenario: Grandparent with Memoized and Unmemoized Parents + +Consider this component tree: + +``` +Grandparent (has state) +├── MemoizedParent (memo=True, renders child) +│ └── ChildA (has state) +└── UnmemoizedParent (renders child) + └── ChildB (has state) +``` + +```python +@ui.component +def child_a(): + count, set_count = ui.use_state(0) + return ui.action_button(f"ChildA: {count}", on_press=lambda _: set_count(count + 1)) + + +@ui.component +def child_b(): + count, set_count = ui.use_state(0) + return ui.action_button(f"ChildB: {count}", on_press=lambda _: set_count(count + 1)) + + +@ui.component(memo=True) +def memoized_parent(prop_value: int): + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + +@ui.component +def unmemoized_parent(prop_value: int): + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + +@ui.component +def grandparent(): + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(prop_value=42), # Always receives same prop + unmemoized_parent(prop_value=gp_state), # Receives changing prop + ) +``` + +### Scenario 1: Grandparent state changes but does NOT affect MemoizedParent's props + +**Action**: Click Grandparent's button (changes `gp_state` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ✅ Yes | Its state changed | +| MemoizedParent | ❌ No | Props unchanged (`prop_value=42`) | +| ChildA | ❌ No | Parent skipped, child state unchanged | +| UnmemoizedParent | ✅ Yes | Not memoized, parent re-rendered | +| ChildB | ✅ Yes | Parent re-rendered | + +### Scenario 2: Grandparent state changes AND affects MemoizedParent's props + +**Setup modification**: `memoized_parent(prop_value=gp_state)` (props now depend on grandparent state) + +**Action**: Click Grandparent's button (changes `gp_state` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ✅ Yes | Its state changed | +| MemoizedParent | ✅ Yes | Props changed (`prop_value` 0→1) | +| ChildA | ✅ Yes | Parent re-rendered | +| UnmemoizedParent | ✅ Yes | Not memoized, parent re-rendered | +| ChildB | ✅ Yes | Parent re-rendered | + +### Scenario 3: Child state changes (within memoized parent) + +**Action**: Click ChildA's button (changes ChildA's internal `count` from 0 to 1) + +**Expected behavior**: +| Component | Should Re-render? | Reason | +|-----------|-------------------|--------| +| Grandparent | ❌ No | Its state unchanged | +| MemoizedParent | ❌ No | Props unchanged | +| ChildA | ✅ Yes | **Its own state changed** | +| UnmemoizedParent | ❌ No | Parent unchanged | +| ChildB | ❌ No | Its state unchanged | + +**This is the bug**: Currently, ChildA does NOT re-render because MemoizedParent's memoization check short-circuits before checking if ChildA has dirty state. + +### Required Fix + +The renderer must support selective re-rendering of dirty descendants even when a memoized parent is skipped. This requires two changes: + +#### 1. Propagate re-renders down to dirty children + +When a child context is dirty and re-renders, all of its children (and children's children) must also re-render **unless** they are memoized and not dirty themselves. This ensures that: + +- Dirty components always re-render +- Non-memoized children of re-rendered parents always re-render (current behavior) +- Memoized children can still skip if their props haven't changed and they're not dirty + +#### 2. Add `get_existing_child_context` to RenderContext + +When a memoized component is skipped (props unchanged, not dirty), it still needs to check if any of its children need re-rendering. To do this, the renderer must: + +1. Iterate over the cached rendered node's children +2. For each child that is an Element, call `_render_child_item` but use a new `get_existing_child_context` method instead of `get_child_context` + +The new `get_existing_child_context` method: + +- Returns the existing child context for the given key +- **Throws** if the context doesn't exist (this would indicate a bug - the child should have been rendered before) +- **Does NOT** add the key to `_collected_contexts` (since we're not re-rendering the parent, we don't want to affect its context collection) + +```python +def get_existing_child_context(self, key: ContextKey) -> "RenderContext": + """ + Get an existing child context for the given key. + + Unlike get_child_context, this: + - Throws if the context doesn't exist + - Does NOT add the key to _collected_contexts + + Used when a memoized parent is skipped but we need to check/render dirty children. + """ + return self._children_context[key] +``` + +#### Renderer changes + +In `_render_element`, when a MemoizedElement's props are equal and context is not dirty: + +```python +if ( + prev_rendered_node is not None + and prev_props is not None + and not context.is_dirty + and element.are_props_equal(prev_props) +): + # Memoized component can be skipped, but we still need to render dirty children + # Use a special render pass that only traverses existing child contexts + _render_dirty_children(prev_rendered_node, context) + return prev_rendered_node +``` + +The `_render_dirty_children` function would: + +1. Walk the cached rendered node tree +2. For each child Element, get the existing child context +3. If the child context is dirty or has dirty descendants, re-render that subtree +4. Otherwise, recursively check that child's children + +This approach ensures: + +- Memoized parents don't re-execute their render function when props are unchanged +- Dirty children within memoized parents still re-render correctly +- Context collection remains correct (we don't add contexts that weren't actually rendered) + +--- + +## Documentation Updates Needed + +1. Add `@ui.memo` to public API docs +2. Add `memo=` parameter to `@ui.component` docs +3. Add "Performance Optimization" guide explaining: + - When to use memoization + - How shallow comparison works + - Best practices (stable references, `use_memo`, `use_callback`) + - Gotchas (new objects on each render) diff --git a/plugins/ui/docs/add-interactivity/memoizing-components.md b/plugins/ui/docs/add-interactivity/memoizing-components.md new file mode 100644 index 000000000..cbc3e42a9 --- /dev/null +++ b/plugins/ui/docs/add-interactivity/memoizing-components.md @@ -0,0 +1,319 @@ +# Memoizing Components + +The `memo` parameter on `@ui.component` optimizes component rendering by skipping re-renders when a component's props haven't changed. This is similar to [React.memo](https://react.dev/reference/react/memo) and is useful for improving performance in components that render often with the same props. + +> [!NOTE] +> The `memo` parameter is for memoizing entire components. To memoize a value or computation within a component, use the [`use_memo`](../hooks/use_memo.md) hook instead. + +## Basic Usage + +Add `memo=True` to your component to skip re-renders when props are unchanged: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def greeting(name): + print(f"Rendering greeting for {name}") + return ui.text(f"Hello, {name}!") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + greeting("World"), # Won't re-render when count changes + direction="column", + ) + + +app_example = app() +``` + +In this example, clicking the button increments `count`, causing `app` to re-render. However, `greeting` will not re-render because its prop (`"World"`) hasn't changed. + +## How It Works + +By default, when a parent component re-renders, all of its child components re-render too. With `memo=True`, `deephaven.ui` compares the new props with the previous props using shallow equality. If all props are equal, the component skips rendering and reuses its previous result. + +The render cycle with memoization: + +1. **Trigger**: Parent component state changes +2. **Render**: Parent re-renders, but memoized children with unchanged props are skipped +3. **Commit**: Only changed parts of the UI are updated + +## When to Use `memo` + +Use `memo=True` when: + +- A component renders often with the same props +- A component is expensive to render (complex calculations, many children) +- A parent component re-renders frequently but passes stable props to children + +Don't use `memo` when: + +- The component's props change on almost every render +- The component is cheap to render +- You're prematurely optimizing without measuring performance + +```python +from deephaven import ui + + +# Good candidate: renders same static content while parent updates +@ui.component(memo=True) +def expensive_chart(data): + # Imagine this does complex data processing + return ui.text(f"Chart with {len(data)} points") + + +# Not a good candidate: props change every render +@ui.component +def live_counter(count): + return ui.text(f"Count: {count}") + + +@ui.component +def dashboard(): + count, set_count = ui.use_state(0) + chart_data = [1, 2, 3, 4, 5] # Static data + + return ui.flex( + ui.button("Update", on_press=lambda: set_count(count + 1)), + live_counter(count), # No benefit from memo - count always changes + expensive_chart(chart_data), # Benefits from memo - data is stable + direction="column", + ) + + +dashboard_example = dashboard() +``` + +## Custom Comparison Function + +By default, `memo=True` uses shallow equality to compare props. You can provide a custom comparison function by passing it directly to `memo`: + +```python +from deephaven import ui + + +def compare_by_id(prev_props, next_props): + """Only re-render if the 'id' prop changes.""" + return prev_props.get("id") == next_props.get("id") + + +@ui.component(memo=compare_by_id) +def user_card(id, name, last_updated): + return ui.flex( + ui.text(f"User #{id}"), + ui.text(f"Name: {name}"), + ui.text(f"Updated: {last_updated}"), + direction="column", + ) + + +@ui.component +def user_profile(): + name, set_name = ui.use_state("Alice") + timestamp, set_timestamp = ui.use_state("12:00") + + return ui.flex( + ui.button("Update timestamp", on_press=lambda: set_timestamp("12:01")), + ui.button("Change name", on_press=lambda: set_name("Bob")), + # Only re-renders if id changes, not name or last_updated + user_card(id=1, name=name, last_updated=timestamp), + direction="column", + ) + + +user_profile_example = user_profile() +``` + +The custom comparison function receives two dictionaries: + +- `prev_props`: The props from the previous render +- `next_props`: The props for the current render + +Return `True` to skip re-rendering (props are "equal"), or `False` to re-render. + +### Deep Equality Comparison + +For props containing nested data structures, you might want deep equality: + +```python +from deephaven import ui + + +def deep_equal(prev_props, next_props): + """Compare props using deep equality.""" + import json + + return json.dumps(prev_props, sort_keys=True) == json.dumps( + next_props, sort_keys=True + ) + + +@ui.component(memo=deep_equal) +def data_display(config): + return ui.text(f"Config: {config}") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + # Even though a new dict is created each render, deep_equal + # will detect the values are the same and skip re-rendering + data_display(config={"setting": "value", "enabled": True}), + direction="column", + ) + + +app_example = app() +``` + +### Threshold-Based Comparison + +You can implement more sophisticated comparison logic: + +```python +from deephaven import ui + + +def significant_change(prev_props, next_props, threshold=5): + """Only re-render if value changes by more than threshold.""" + prev_value = prev_props.get("value", 0) + next_value = next_props.get("value", 0) + return abs(next_value - prev_value) <= threshold + + +@ui.component(memo=significant_change) +def progress_bar(value): + return ui.progress_bar(value=value, label=f"{value}%") + + +@ui.component +def app(): + value, set_value = ui.use_state(0) + + return ui.flex( + ui.button("+1", on_press=lambda: set_value(value + 1)), + ui.button("+10", on_press=lambda: set_value(value + 10)), + # Only re-renders when value changes by more than 5 + progress_bar(value=value), + direction="column", + ) + + +app_example = app() +``` + +## Syntax Options + +The `memo` parameter accepts different values: + +```python skip-test +# Memoization disabled (default behavior) +@ui.component +def my_component(prop): + return ui.text(prop) + + +# Memoization with shallow comparison +@ui.component(memo=True) +def my_memoized_component(prop): + return ui.text(prop) + + +# Memoization with custom comparison function +@ui.component(memo=my_custom_compare) +def my_component_custom(prop): + return ui.text(prop) +``` + +## Common Pitfalls + +### Creating New Objects in Props + +When you pass a new object, list, or dictionary as a prop, it will always be a different reference, causing re-renders even if the content is the same: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def item_list(items): + return ui.flex(*[ui.text(item) for item in items], direction="column") + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + # ❌ Creates a new list on every render + # item_list will re-render every time even though content is the same + items_bad = ["apple", "banana"] + + # ✅ Use use_memo to keep the same reference + items_good = ui.use_memo(lambda: ["apple", "banana"], []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + item_list(items_good), # Won't re-render unnecessarily + direction="column", + ) + + +app_example = app() +``` + +### Passing Callback Functions + +Lambda functions and inline function definitions create new references each render: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def button_row(on_click): + return ui.button("Click me", on_press=on_click) + + +@ui.component +def app(): + count, set_count = ui.use_state(0) + + # ❌ Creates a new function reference every render + handle_click_bad = lambda: print("clicked") + + # ✅ Use use_callback to memoize the function + handle_click_good = ui.use_callback(lambda: print("clicked"), []) + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + button_row(on_click=handle_click_good), # Won't re-render unnecessarily + direction="column", + ) + + +app_example = app() +``` + +## Comparison with `use_memo` + +| Feature | `memo` parameter | `use_memo` | +| ------- | ----------------------------- | ---------------------- | +| Purpose | Skip re-rendering a component | Cache a computed value | +| Usage | Parameter on `@ui.component` | Hook inside component | +| Input | Component props | Dependencies array | +| Output | Memoized component | Memoized value | + +Use `memo=True` on `@ui.component` to optimize component rendering. Use `use_memo` to optimize expensive calculations within a component. diff --git a/plugins/ui/docs/add-interactivity/render-cycle.md b/plugins/ui/docs/add-interactivity/render-cycle.md index c822dfb26..08fde95d9 100644 --- a/plugins/ui/docs/add-interactivity/render-cycle.md +++ b/plugins/ui/docs/add-interactivity/render-cycle.md @@ -125,3 +125,39 @@ clock_example = clock_wrapper() This works because during this last step, React only updates the content of `ui.header` with the new time. It sees that the `ui.text_field` appears in the JSX in the same place as last time, so React doesn’t touch the `ui.text_field` or its value. After rendering is done and React updated the DOM, the browser will repaint the screen. + +## Optimizing Re-renders with `memo` + +By default, when any component's state changes, `deephaven.ui` re-renders the entire component tree from the root—not just the component that triggered the change or its children, but every component in the tree. This is usually not a problem, but if you have a deeply nested tree or expensive components, you can optimize performance by using the `memo` parameter on `@ui.component`. + +The `memo` parameter tells `deephaven.ui` to skip re-rendering a component when its props haven't changed: + +```python +from deephaven import ui + + +@ui.component(memo=True) +def expensive_child(value): + # This component will only re-render when `value` changes + return ui.text(f"Value: {value}") + + +@ui.component +def parent(): + count, set_count = ui.use_state(0) + static_value = "hello" + + return ui.flex( + ui.button("Increment", on_press=lambda: set_count(count + 1)), + ui.text(f"Count: {count}"), + # This child won't re-render when count changes because static_value stays the same + expensive_child(static_value), + ) + + +parent_example = parent() +``` + +In this example, clicking the button updates `count`, which causes `parent` to re-render. However, `expensive_child` will skip re-rendering because its `value` prop (`"hello"`) hasn't changed. + +For more details on when and how to use memoization effectively, see [Memoizing Components](./memoizing-components.md). diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 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" 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 diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 3a2a797b3..052bd62b4 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,12 +466,29 @@ 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": + 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( @@ -551,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. @@ -585,3 +629,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..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, @@ -950,3 +962,19 @@ def is_iterable(value: Any) -> bool: True if the value is a standard iterable type. """ return isinstance(value, (list, tuple, set, dict, map, filter, range)) + + +def dict_shallow_equal(dict1: Mapping[str, Any], dict2: Mapping[str, Any]) -> bool: + """ + Check if two dictionaries are shallowly equal. By default Python does a deep equals check, but for props comparison we may just want a shallow equals. + + Args: + dict1: The first dict to compare. + dict2: The second dict to compare. + """ + if dict1.keys() != dict2.keys(): + return False + for key in dict1: + if dict1[key] is not dict2[key]: + return False + return True diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index e701a1da7..2738bc638 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -48,7 +48,7 @@ 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 .menu import menu from .menu_trigger import menu_trigger 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/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/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/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/component.py b/plugins/ui/src/deephaven/ui/components/component.py new file mode 100644 index 000000000..d8e5ab418 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/component.py @@ -0,0 +1,107 @@ +from __future__ import annotations +import functools +import logging +from typing import Any, Callable, overload +from .._internal import get_component_qualname, dict_shallow_equal +from ..elements import Element, FunctionElement, MemoizedElement, PropsType + +logger = logging.getLogger(__name__) + + +# Type alias for comparison functions +CompareFunction = Callable[[PropsType, PropsType], bool] + + +@overload +def component(func: Callable[..., Any]) -> Callable[..., Element]: + """Basic usage without parentheses: @ui.component""" + ... + + +@overload +def component( + *, + memo: bool | CompareFunction = ..., +) -> Callable[[Callable[..., Any]], Callable[..., Element]]: + """Usage with parameters: @ui.component() or @ui.component(memo=True)""" + ... + + +def component( + func: Callable[..., Any] | None = None, + *, + memo: bool | CompareFunction = False, +) -> Callable[..., Element] | Callable[[Callable[..., Any]], Callable[..., Element]]: + """ + Create a FunctionalElement from the passed in function. + + Args: + func: The function to create a FunctionalElement from. + Runs when the component is being rendered. + memo: Enable memoization to skip re-rendering when props are unchanged. + - False (default): No memoization, component always re-renders with parent. + - True: Enable memoization with shallow equality comparison. + - Callable: Custom comparison function (prev_props, next_props) -> bool + that returns True if props are equal (should skip re-render). + + Can be used in several ways: + + 1. Without parentheses (no memoization): + @ui.component + def my_component(value): + return ui.text(str(value)) + + 2. With parentheses (no memoization): + @ui.component() + def my_component(value): + return ui.text(str(value)) + + 3. With memo=True (shallow equality comparison): + @ui.component(memo=True) + def my_component(value): + return ui.text(str(value)) + + 4. With custom comparison function: + @ui.component(memo=lambda prev, next: prev["value"] == next["value"]) + def my_component(value, on_click): + return ui.button(str(value), on_press=on_click) + """ + # Determine if memoization is enabled and what comparison function to use + if memo is False: + enable_memo = False + compare_fn: CompareFunction | None = None + elif memo is True: + enable_memo = True + compare_fn = dict_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[..., 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 + + 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/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/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/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/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/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/make_component.py b/plugins/ui/src/deephaven/ui/components/make_component.py deleted file mode 100644 index 4d045b349..000000000 --- a/plugins/ui/src/deephaven/ui/components/make_component.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations -import functools -import logging -from typing import Any, Callable -from .._internal import get_component_qualname -from ..elements import FunctionElement - -logger = logging.getLogger(__name__) - - -def make_component(func: Callable[..., Any]): - """ - Create a FunctionalElement from the passed in function. - - Args: - func: The function to create a FunctionalElement from. - Runs when the component is being rendered. - """ - - @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 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/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/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/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/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/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/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/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/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 9cc5a0163..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,8 +48,8 @@ def name(self) -> str: return self._name @property - def key(self) -> str | None: + def key(self) -> Key | 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..61fc5599b 100644 --- a/plugins/ui/src/deephaven/ui/elements/Element.py +++ b/plugins/ui/src/deephaven/ui/elements/Element.py @@ -1,10 +1,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Dict, List, Union -from .._internal import RenderContext +from typing import Any, List, Mapping, Tuple, Union -PropsType = Dict[str, Any] +PropsType = Mapping[str, Any] class Element(ABC): @@ -33,14 +32,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. """ @@ -48,4 +44,6 @@ def render(self, context: RenderContext) -> 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, float, int, str, Element, List["NodeType"], Tuple["NodeType", ...] +] 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..dbdec25fb 100644 --- a/plugins/ui/src/deephaven/ui/renderer/Renderer.py +++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py @@ -4,13 +4,18 @@ from typing import Any, Union from .._internal import RenderContext, remove_empty_keys -from ..elements import Element, PropsType +from ..elements import Element, MemoizedElement, PropsType from .RenderedNode import RenderedNode logger = logging.getLogger(__name__) -def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) -> Any: +def _render_child_item( + item: Any, + parent_context: RenderContext, + index_key: str, + is_dirty_render: bool, +) -> Any: """ Render a child item. If the item may have its own children, they will be rendered as well. @@ -18,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,33 +154,77 @@ def _render_dict_in_open_context(item: PropsType, context: RenderContext) -> Pro Args: item: The dictionary to render. context: The context to render the dictionary in. + is_dirty_render: Whether we are re-rendering an existing element. This is used to determine whether to use the existing child context or create a new one when rendering child elements. Returns: The rendered dictionary. """ - return {key: _render_child_item(value, context, key) for key, value in item.items()} + return { + key: _render_child_item(value, context, key, is_dirty_render) + for key, value in item.items() + } -def _render_element(element: Element, context: RenderContext) -> RenderedNode: +def _render_element( + element: Element, context: RenderContext, is_dirty_render: bool +) -> RenderedNode: """ Render an Element. Args: element: The element to render. context: The context to render the component in. + is_dirty_render: Whether this render is a dirty render (a result of a state change), or we are just traversing the tree. Returns: The RenderedNode representing the element. """ - logger.debug("Rendering element %s in context %s", element.name, context) + logger.debug( + "Rendering element %s (%s) in context %s, cache: %s", + element.name, + type(element), + context, + context.cache, + ) + + # Props that are being passed into this Element + element_props = None + + # Props that are returned after calling the elements render() method. These will be cached + rendered_element_props = None + + if isinstance(element, MemoizedElement): + element_props = element.props + + if context.cache is not None: + # First check if we can use the result from the cache + prev_props, prev_rendered_element_props = context.cache + + needs_render = is_dirty_render + + if isinstance(element, MemoizedElement): + needs_render = not element.are_props_equal(prev_props) + + if not needs_render and not context.is_dirty: + logger.debug("Returning cached element %s", element.name) + rendered_props = _render_dict_in_open_context( + prev_rendered_element_props, context, False + ) + return RenderedNode(element.name, rendered_props) with context.open(): - props = element.render(context) + logger.debug("Rendering element %s", element.name) + + rendered_element_props = element.render() + + context.cache = (element_props, rendered_element_props) # We also need to render any elements that are passed in as props (including `children`) - props = _render_dict_in_open_context(props, context) + rendered_props = _render_dict_in_open_context( + rendered_element_props, context, True + ) - return RenderedNode(element.name, props) + return RenderedNode(element.name, rendered_props) class Renderer: @@ -132,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 @@ -152,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 new file mode 100644 index 000000000..8992cb9e4 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_memo.py @@ -0,0 +1,1235 @@ +""" +Tests for component memoization (memo parameter on @ui.component). + +The memo parameter on @ui.component allows components to skip re-rendering when their props haven't +changed, similar to React.memo(). +""" + +from __future__ import annotations +from unittest.mock import Mock +from typing import Any, Callable, List, Union +from deephaven.ui.renderer.Renderer import Renderer +from deephaven.ui.renderer.RenderedNode import RenderedNode +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven import ui +from .BaseTest import BaseTestCase + +run_on_change: OnChangeCallable = lambda x: x() + + +class MemoTestCase(BaseTestCase): + """Tests for component memoization (memo parameter on @ui.component).""" + + def _find_node(self, root: RenderedNode, name: str) -> RenderedNode: + """Helper to find a node by name in the rendered tree.""" + if root.name == name: + return root + children: Union[Any, List[Any]] = ( + root.props.get("children", []) if root.props is not None else [] + ) + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + try: + return self._find_node(child, name) + except ValueError: + pass + raise ValueError(f"Could not find node with name {name}") + + def _find_action_button(self, root: RenderedNode) -> RenderedNode: + return self._find_node(root, "deephaven.ui.components.ActionButton") + + def test_memo_skips_rerender_with_same_props(self): + """Test that memo=True skips re-render when props are unchanged.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + # Pass same value to child regardless of parent state + return ui.flex( + ui.action_button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (change parent state) + button = self._find_action_button(result) + button.props["onPress"](None) + + # Re-render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) # Parent re-rendered + self.assertEqual(child_render_count[0], 1) # Child SKIPPED (memoized) + + def test_memo_rerenders_when_props_change(self): + """Test that memo=True re-renders when props change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Increment: {value}", + on_press=lambda _: set_value(value + 1), + ), + memoized_child(value=value), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change the prop value by clicking the button + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Child re-rendered (props changed) + + def test_memo_rerenders_when_own_state_changes(self): + """Test that memo=True re-renders when the memoized component's own state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + internal_state, set_internal_state = ui.use_state(0) + return ui.action_button( + f"Value: {value}, Internal: {internal_state}", + on_press=lambda _: set_internal_state(internal_state + 1), + ) + + @ui.component + def parent(): + # Always pass the same props + return memoized_child(value=42) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change state within memoized component + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + # Child should re-render because its own state changed (context is dirty) + self.assertEqual(child_render_count[0], 2) + + def test_memo_rerenders_when_both_props_and_state_change(self): + """Test that memo=True re-renders when both props and internal state change.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + button_ref = [None] + parent_setter_ref = [None] + + @ui.component(memo=True) + def memoized_child(value: int): + child_render_count[0] += 1 + internal_state, set_internal_state = ui.use_state(0) + btn = ui.action_button( + f"Value: {value}, Internal: {internal_state}", + on_press=lambda _: set_internal_state(internal_state + 1), + ) + button_ref[0] = btn + return btn + + @ui.component + def parent(): + prop_value, set_prop_value = ui.use_state(0) + parent_setter_ref[0] = set_prop_value + return memoized_child(value=prop_value) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Change both props (via parent) and internal state + button = self._find_action_button(result) + button.props["onPress"](None) # Change internal state + parent_setter_ref[0](1) # Change props + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered due to both changes + + def test_memo_no_rerender_when_nothing_changes(self): + """Test that memo=True doesn't re-render when nothing changes (forced parent re-render).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(): + child_render_count[0] += 1 + return ui.text("Static content") + + @ui.component + def parent(): + parent_render_count[0] += 1 + counter, set_counter = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Count: {counter}", + on_press=lambda _: set_counter(counter + 1), + ), + memoized_child(), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Force several parent re-renders + for i in range(3): + button = self._find_action_button(result) + button.props["onPress"](None) + result = renderer.render(parent()) + + self.assertEqual(parent_render_count[0], 4) # Parent re-rendered 4 times total + self.assertEqual(child_render_count[0], 1) # Child NEVER re-rendered + + def test_memo_with_custom_compare(self): + """Test that custom compare function controls memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that only checks 'value', ignores 'on_click' + def compare_only_value(prev, next): + return prev.get("value") == next.get("value") + + @ui.component(memo=compare_only_value) + def child_with_callback(value: int, on_click): + child_render_count[0] += 1 + return ui.action_button(str(value), on_press=on_click) + + @ui.component + def parent(): + count, set_count = ui.use_state(0) + # Create new callback on each render (normally would cause re-render) + callback = lambda _: set_count(count + 1) + return ui.flex( + ui.action_button( + f"Parent count: {count}", + on_press=lambda _: set_count(count + 1), + ), + child_with_callback(value=42, on_click=callback), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render (creates new callback, but custom compare ignores it) + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + # Child SKIPPED because custom compare only checks 'value' which is still 42 + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_deep_equality(self): + """Test custom compare with deep equality for object props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that does deep equality on lists + def deep_equal_items(prev, next): + prev_items = prev.get("children", [[]])[0] # positional arg + next_items = next.get("children", [[]])[0] + return prev_items == next_items # List equality compares contents + + @ui.component(memo=deep_equal_items) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(items)) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Creates new list object each render, but with same contents + items = [1, 2, 3] + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render - new list object but same contents + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # SKIPPED because custom compare does deep equality + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_always_rerender(self): + """Test custom compare that always returns False (always re-renders).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that always returns False - props are never "equal" + def always_different(prev, next): + return False + + @ui.component(memo=always_different) + def always_rerender_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + always_rerender_child(value=42), # Same props every time + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Re-rendered because custom compare returns False + self.assertEqual(child_render_count[0], 2) + + def test_memo_custom_compare_always_skip(self): + """Test custom compare that always returns True (never re-renders).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Custom compare that always returns True - props are always "equal" + def always_equal(prev, next): + return True + + @ui.component(memo=always_equal) + def never_rerender_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + never_rerender_child(value=state), # Props actually change! + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render - props change but custom compare says they're equal + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # SKIPPED even though props changed, because custom compare returns True + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_selective_props(self): + """Test custom compare that checks only specific props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + # Only re-render if 'important_value' changes, ignore 'metadata' and 'callback' + def compare_important_only(prev, next): + return prev.get("important_value") == next.get("important_value") + + @ui.component(memo=compare_important_only) + def selective_child(important_value: int, metadata: dict, callback): + child_render_count[0] += 1 + return ui.action_button(f"Important: {important_value}", on_press=callback) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # metadata changes each render, but important_value stays the same + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + selective_child( + important_value=42, + metadata={"render_count": state}, # Changes each time + callback=lambda _: None, # New function each time + ), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger multiple re-renders - metadata and callback change, important_value doesn't + for _ in range(3): + button = self._find_action_button(result) + button.props["onPress"](None) + result = renderer.render(parent()) + + # Child never re-rendered because important_value stayed at 42 + self.assertEqual(child_render_count[0], 1) + + def test_memo_custom_compare_with_threshold(self): + """Test custom compare that only re-renders on significant changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + parent_set_value = [None] + + # Only re-render if value changes by more than 5 + def significant_change_only(prev, next): + prev_val = prev.get("children", [[0]])[0] # positional arg + next_val = next.get("children", [[0]])[0] + return abs(next_val - prev_val) <= 5 + + @ui.component(memo=significant_change_only) + def threshold_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + value, set_value = ui.use_state(0) + parent_set_value[0] = set_value + return ui.flex( + threshold_child(value), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Small change (within threshold) - should skip + parent_set_value[0](3) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Another small change - should skip + parent_set_value[0](5) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Big change (exceeds threshold) - should re-render + parent_set_value[0](15) + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) + + def test_memo_with_object_props_same_reference(self): + """Test memoization behavior with object props (same reference).""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + # Same list object each time (defined outside component) + shared_list = [1, 2, 3] + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=shared_list), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (same list reference) + + def test_memo_with_object_props_new_reference(self): + """Test that memoization re-renders with new object references.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def child_with_list(items: list): + child_render_count[0] += 1 + return ui.text(str(len(items))) + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Creates new list object each render + items = [1, 2, 3] + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + child_with_list(items=items), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (new list reference) + + def test_memo_nested_components(self): + """Test memoization with nested components.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_count = [0] + + @ui.component(memo=True) + def memoized_child(value: int): + child_count[0] += 1 + return ui.text(f"Child: {value}") + + @ui.component(memo=True) + def memoized_parent(value: int): + parent_count[0] += 1 + return ui.flex( + ui.text(f"Parent: {value}"), + memoized_child(value=value), + ) + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + self.assertEqual(child_count[0], 1) + + # Trigger grandparent re-render with same props to children + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 2) # Grandparent re-rendered + self.assertEqual(parent_count[0], 1) # Parent SKIPPED (props unchanged) + self.assertEqual(child_count[0], 1) # Child SKIPPED (parent didn't re-render) + + def test_memo_nested_with_internal_state(self): + """Test memoization with nested components where inner has state.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_count = [0] + parent_count = [0] + child_state_setter = [None] + + @ui.component(memo=True) + def memoized_parent(value: int): + parent_count[0] += 1 + child_state, set_child_state = ui.use_state("initial") + child_state_setter[0] = set_child_state + return ui.text(f"{value}: {child_state}") + + @ui.component + def grandparent(): + grandparent_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(gp_state), on_press=lambda _: set_gp_state(gp_state + 1) + ), + memoized_parent(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_count[0], 1) + self.assertEqual(parent_count[0], 1) + + # Change state within memoized component (dirty tracking should work) + child_state_setter[0]("updated") + result = renderer.render(grandparent()) + # grandparent component function should not re-run because it's own state didn't change + self.assertEqual( + grandparent_count[0], 1 + ) # Grandparent re-rendered (root element) + # parent_count should be 2 because its own context is dirty (state changed) + self.assertEqual(parent_count[0], 2) # Parent re-rendered (own state dirty) + + # Now trigger grandparent re-render with same props to memoized_parent + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(grandparent()) + self.assertEqual( + grandparent_count[0], 2 + ) # Grandparent re-rendered (state changed) + self.assertEqual( + parent_count[0], 2 + ) # Parent SKIPPED (props unchanged, not dirty) + + def test_memo_with_multiple_props(self): + """Test memoization with multiple props.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(a: int, b: str, c: bool): + child_render_count[0] += 1 + return ui.text(f"{a}-{b}-{c}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(a=1, b="hello", c=True), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render with same props + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (all props same) + + def test_memo_with_one_prop_changed(self): + """Test memoization re-renders when one of multiple props changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(a: int, b: str, c: bool): + child_render_count[0] += 1 + return ui.text(f"{a}-{b}-{c}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + # Only 'a' changes with state + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(a=state, b="hello", c=True), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render - prop 'a' changes + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 2) # Re-rendered (prop 'a' changed) + + def test_memo_with_children_prop(self): + """Test memoization with children passed as positional args.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + wrapper_render_count = [0] + + @ui.component(memo=True) + def memoized_wrapper(child_element): + wrapper_render_count[0] += 1 + return ui.view(child_element) + + # Create a stable child element + stable_child = ui.text("Static child") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_wrapper(stable_child), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(wrapper_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Should 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.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component(memo=True) + def memoized_child(value): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + memoized_child(value=None), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render with same None prop + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) # SKIPPED (None == None) + + def test_non_memoized_always_rerenders(self): + """Test that non-memoized components always re-render with parent.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + parent_render_count = [0] + child_render_count = [0] + + @ui.component + def non_memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + parent_render_count[0] += 1 + parent_state, set_parent_state = ui.use_state(0) + return ui.flex( + ui.action_button( + str(parent_state), + on_press=lambda _: set_parent_state(parent_state + 1), + ), + non_memoized_child(value=42), # Same props each time + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 1) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + result = renderer.render(parent()) + self.assertEqual(parent_render_count[0], 2) + # Non-memoized child should re-render even with same props + self.assertEqual(child_render_count[0], 2) + + def test_memo_component_with_parentheses_no_args(self): + """Test that @ui.component() (with empty parens) still works without memoization.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + child_render_count = [0] + + @ui.component() + def non_memoized_child(value: int): + child_render_count[0] += 1 + return ui.text(f"Value: {value}") + + @ui.component + def parent(): + state, set_state = ui.use_state(0) + return ui.flex( + ui.action_button(str(state), on_press=lambda _: set_state(state + 1)), + non_memoized_child(value=42), + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + result = renderer.render(parent()) + self.assertEqual(child_render_count[0], 1) + + # Trigger parent re-render + button = self._find_action_button(result) + button.props["onPress"](None) + + renderer.render(parent()) + # Should re-render because component is not memoized + self.assertEqual(child_render_count[0], 2) + + def test_memo_child_with_internal_state(self): + """Test that a memoized component's child with internal state renders correctly when state changes.""" + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + memoized_render_count = [0] + stateful_child_render_count = [0] + + @ui.component + def stateful_child(): + """A non-memoized child component with internal state.""" + stateful_child_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"Child count: {count}", + on_press=lambda _: set_count(count + 1), + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + """A memoized parent that renders a stateful child.""" + memoized_render_count[0] += 1 + return ui.flex( + ui.text(f"Prop: {prop_value}"), + stateful_child(), + ) + + @ui.component + def root(): + """Root component that renders the memoized parent with same props.""" + return memoized_parent(prop_value=42) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(root()) + self.assertEqual(memoized_render_count[0], 1) + self.assertEqual(stateful_child_render_count[0], 1) + + # Find the child's button and click it to change internal state + button = self._find_action_button(result) + self.assertEqual(button.props["children"], "Child count: 0") + + # Click the button to update child's internal state + button.props["onPress"](None) + + # Re-render + result = renderer.render(root()) + + # The memoized parent should NOT re-render (props unchanged) + # But the stateful child SHOULD re-render (its state changed) + self.assertEqual(memoized_render_count[0], 1) # Memoized parent skipped + self.assertEqual(stateful_child_render_count[0], 2) # Child re-rendered + + # Verify the child's state was actually updated in the rendered output + button = self._find_action_button(result) + self.assertEqual(button.props["children"], "Child count: 1") + + def _find_action_buttons(self, root: RenderedNode) -> list[RenderedNode]: + """Helper to find all action buttons in the rendered tree.""" + buttons = [] + if root.name == "deephaven.ui.components.ActionButton": + buttons.append(root) + children = root.props.get("children", []) if root.props is not None else [] + if not isinstance(children, list): + children = [children] + for child in children: + if isinstance(child, RenderedNode): + buttons.extend(self._find_action_buttons(child)) + return buttons + + def test_selective_rerender_scenario1_grandparent_state_no_prop_change(self): + """ + Scenario 1: Grandparent state changes but does NOT affect MemoizedParent's props. + + Expected: + - Grandparent: re-renders (state changed) + - MemoizedParent: skipped (props unchanged) + - ChildA: skipped (parent skipped, own state unchanged) + - UnmemoizedParent: re-renders (not memoized) + - ChildB: re-renders (parent re-rendered) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=42), # Always same prop + unmemoized_parent(prop_value=gp_state), # Prop changes with state + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find grandparent's button and click it + buttons = self._find_action_buttons(result) + gp_button = next(b for b in buttons if "Grandparent:" in b.props["children"]) + gp_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # Grandparent re-rendered (state changed) + self.assertEqual(grandparent_render_count[0], 2) + # MemoizedParent skipped (props unchanged: prop_value=42) + self.assertEqual(memoized_parent_render_count[0], 1) + # ChildA skipped (parent skipped, own state unchanged) + self.assertEqual(child_a_render_count[0], 1) + # UnmemoizedParent re-rendered (not memoized, parent re-rendered) + self.assertEqual(unmemoized_parent_render_count[0], 2) + # ChildB re-rendered (parent re-rendered) + self.assertEqual(child_b_render_count[0], 2) + + def test_selective_rerender_scenario2_grandparent_state_with_prop_change(self): + """ + Scenario 2: Grandparent state changes AND affects MemoizedParent's props. + + Expected: + - Grandparent: re-renders (state changed) + - MemoizedParent: re-renders (props changed) + - ChildA: re-renders (parent re-rendered) + - UnmemoizedParent: re-renders (not memoized) + - ChildB: re-renders (parent re-rendered) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=gp_state), # Prop changes with state + unmemoized_parent(prop_value=gp_state), # Prop changes with state + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find grandparent's button and click it + buttons = self._find_action_buttons(result) + gp_button = next(b for b in buttons if "Grandparent:" in b.props["children"]) + gp_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # All components should re-render + self.assertEqual(grandparent_render_count[0], 2) + self.assertEqual(memoized_parent_render_count[0], 2) # Props changed + self.assertEqual(child_a_render_count[0], 2) + self.assertEqual(unmemoized_parent_render_count[0], 2) + self.assertEqual(child_b_render_count[0], 2) + + def test_selective_rerender_scenario3_child_state_change_only(self): + """ + Scenario 3: Child state changes (within memoized parent). + + Expected: + - Grandparent: NOT re-rendered (state unchanged) + - MemoizedParent: NOT re-rendered (props unchanged) + - ChildA: re-renders (its own state changed) + - UnmemoizedParent: NOT re-rendered (parent unchanged) + - ChildB: NOT re-rendered (state unchanged) + """ + on_change = Mock(side_effect=run_on_change) + on_queue = Mock(side_effect=run_on_change) + + grandparent_render_count = [0] + memoized_parent_render_count = [0] + unmemoized_parent_render_count = [0] + child_a_render_count = [0] + child_b_render_count = [0] + + @ui.component + def child_a(): + child_a_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildA: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component + def child_b(): + child_b_render_count[0] += 1 + count, set_count = ui.use_state(0) + return ui.action_button( + f"ChildB: {count}", on_press=lambda _: set_count(count + 1) + ) + + @ui.component(memo=True) + def memoized_parent(prop_value: int): + memoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"MemoizedParent prop: {prop_value}"), child_a()) + + @ui.component + def unmemoized_parent(prop_value: int): + unmemoized_parent_render_count[0] += 1 + return ui.flex(ui.text(f"UnmemoizedParent prop: {prop_value}"), child_b()) + + @ui.component + def grandparent(): + grandparent_render_count[0] += 1 + gp_state, set_gp_state = ui.use_state(0) + return ui.flex( + ui.action_button( + f"Grandparent: {gp_state}", + on_press=lambda _: set_gp_state(gp_state + 1), + ), + memoized_parent(prop_value=42), # Always same prop + unmemoized_parent(prop_value=42), # Always same prop + ) + + rc = RenderContext(on_change, on_queue) + renderer = Renderer(rc) + + # Initial render + result = renderer.render(grandparent()) + self.assertEqual(grandparent_render_count[0], 1) + self.assertEqual(memoized_parent_render_count[0], 1) + self.assertEqual(unmemoized_parent_render_count[0], 1) + self.assertEqual(child_a_render_count[0], 1) + self.assertEqual(child_b_render_count[0], 1) + + # Find ChildA's button and click it to change its internal state + buttons = self._find_action_buttons(result) + child_a_button = next(b for b in buttons if "ChildA:" in b.props["children"]) + self.assertEqual(child_a_button.props["children"], "ChildA: 0") + child_a_button.props["onPress"](None) + + # Re-render + result = renderer.render(grandparent()) + + # Grandparent should NOT re-render (state unchanged) + self.assertEqual(grandparent_render_count[0], 1) + # MemoizedParent should NOT re-render (props unchanged) + self.assertEqual(memoized_parent_render_count[0], 1) + # ChildA SHOULD re-render (its state changed) + self.assertEqual(child_a_render_count[0], 2) + # UnmemoizedParent should NOT re-render + self.assertEqual(unmemoized_parent_render_count[0], 1) + # ChildB should NOT re-render + self.assertEqual(child_b_render_count[0], 1) + + # Verify ChildA's state was actually updated in the rendered output + buttons = self._find_action_buttons(result) + child_a_button = next(b for b in buttons if "ChildA:" in b.props["children"]) + self.assertEqual(child_a_button.props["children"], "ChildA: 1") + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/plugins/ui/test/deephaven/ui/test_renderer.py b/plugins/ui/test/deephaven/ui/test_renderer.py index 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( 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) diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py index 31697a2da..421abe10c 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -6,6 +6,7 @@ from deephaven.ui._internal.utils import ( convert_dict_keys, create_props, + dict_shallow_equal, dict_to_camel_case, dict_to_react_props, get_component_name, @@ -471,6 +472,52 @@ def __iter__(self): self.assertFalse(is_iterable(CustomIterable())) + def test_dict_shallow_equal(self): + # Two empty dicts are equal + self.assertTrue(dict_shallow_equal({}, {})) + + # Same keys with identical values (same object) should be equal + obj1 = {"nested": "value"} + obj2 = [1, 2, 3] + dict1 = {"a": obj1, "b": obj2} + dict2 = {"a": obj1, "b": obj2} + self.assertTrue(dict_shallow_equal(dict1, dict2)) + + # Same keys with equal but not identical values should NOT be equal + dict3 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + dict4 = {"a": {"nested": "value"}, "b": [1, 2, 3]} + self.assertFalse(dict_shallow_equal(dict3, dict4)) + + # Different keys should not be equal + self.assertFalse(dict_shallow_equal({"a": 1}, {"b": 1})) + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 1, "b": 2})) + self.assertFalse(dict_shallow_equal({"a": 1, "b": 2}, {"a": 1})) + + # Primitives: small ints and interned strings have the same identity + self.assertTrue( + dict_shallow_equal({"a": 1, "b": "hello"}, {"a": 1, "b": "hello"}) + ) + self.assertTrue(dict_shallow_equal({"x": None}, {"x": None})) + self.assertTrue( + dict_shallow_equal({"x": True, "y": False}, {"x": True, "y": False}) + ) + + # Different primitive values + self.assertFalse(dict_shallow_equal({"a": 1}, {"a": 2})) + self.assertFalse(dict_shallow_equal({"a": "foo"}, {"a": "bar"})) + + # Test with callables - same function object + def my_func(): + pass + + self.assertTrue(dict_shallow_equal({"func": my_func}, {"func": my_func})) + + # Different function objects (even with same behavior) should NOT be equal + def my_func2(): + pass + + self.assertFalse(dict_shallow_equal({"func": my_func}, {"func": my_func2})) + if __name__ == "__main__": unittest.main() diff --git a/tests/app.d/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",