Skip to content

feat(ui): Optimize render cycle to only re-render components with state changes#1295

Closed
mofojed wants to merge 12 commits intodeephaven:mainfrom
mofojed:DOC-1127-fix-render-cycle-component-memo
Closed

feat(ui): Optimize render cycle to only re-render components with state changes#1295
mofojed wants to merge 12 commits intodeephaven:mainfrom
mofojed:DOC-1127-fix-render-cycle-component-memo

Conversation

@mofojed
Copy link
Member

@mofojed mofojed commented Feb 6, 2026

  • Previously when any component had a state change, all components in that tree would re-render
  • Optimize it so that it only re-renders the child that has had state modifications
    • Keep track of the previous render
    • Future planning for implementing a ui.memo or a param on a ui.component to memoize a component (e.g. cache it unless props or state have changed
  • Added unit tests to verify functionality
  • Added benchmarks to measure performance
  • Tested using e2e tests, running a few different components

…s and emphasize use_memo for expensive calculations
- Add _is_dirty and _has_dirty_descendant flags to RenderContext
- Add _parent_context to track parent-child relationships
- Implement _mark_dirty() to propagate dirty state to ancestors
- Add tests for dirty tracking behavior
Add cached rendered output and selective re-rendering logic to avoid
re-rendering clean components:

RenderContext changes:
- Add _cached_rendered_node and _cached_props fields
- Store rendered output after each render

Renderer changes:
- Import FunctionElement to distinguish stateful components
- Modify _render_element to check dirty state before re-rendering
- Only FunctionElements (user-defined @ui.component) use caching
- BaseElements (built-in ui.*, like ui.text) always re-render
- Add _render_children_only to traverse clean subtrees
- Add _render_props_without_opening_context helper functions

Behavior changes:
- Clean components return cached rendered output
- Effects with no dependencies only run when component is dirty
- Parent effects preserved when only children re-render

All 120 tests pass.
Fixed a bug where subsequent state updates on child components would
not trigger re-renders after the first update.

Root cause: _mark_dirty() optimizes by stopping propagation when a
parent already has _has_dirty_descendant=True. However, _render_list
and _render_dict weren't clearing this flag after processing children,
leaving stale flags that caused _mark_dirty() to stop early.

Fix: Clear _has_dirty_descendant at the end of _render_list and
_render_dict to ensure proper propagation on subsequent state changes.

Added test_multiple_child_state_updates to verify multiple clicks on
child components trigger the correct re-renders.
Added tests for key-based reconciliation (Phase 4.2):
- test_different_keys_have_separate_state: verifies different keys maintain separate state
- test_changed_key_creates_new_context: verifies changing keys creates fresh contexts
- test_unused_key_context_is_unmounted: verifies unused contexts get unmounted

Added tests for effect behavior with selective re-rendering (Phase 4.3):
- test_clean_component_effects_dont_run: verifies effects don't run when component is clean but has dirty descendants
- test_key_change_unmounts_old_component: verifies key changes properly unmount/remount components

Updated plan document to mark Phase 4 as completed.
Added comprehensive benchmark tests in test_benchmark.py:
- test_large_tree_leaf_state_change: 111 components, achieves 0.90% re-render rate
- test_multiple_leaves_state_change: verifies multiple dirty leaves re-render correctly
- test_render_timing: measures 38-44x speedup for selective vs full re-render
- test_deep_nesting_performance: 20-level deep nesting, only leaf re-renders
- test_sibling_isolation: verifies siblings don't re-render
- test_parent_state_change_rerenders_all: documents parent state change behavior
- test_parent_state_passed_to_children: documents current limitation

Key findings:
- Selective re-render is 38-44x faster than full re-render
- <10% re-render target exceeded (0.90% achieved)
- Current limitation: caching is context-dirty-state based, not props-based

Updated plan document with benchmark results.
When using map() or generators as children (e.g., ui.view(map(...))),
the lazy iterator would get exhausted after the first render. During
selective re-rendering, the cached BaseElement would return its _props
containing the exhausted iterator, resulting in empty children.

Changes:
- Add materialize_lazy_iterators() utility function that recursively
  converts map/generator objects to lists
- Apply materialization in BaseElement.__init__ for both positional
  children and children passed via kwargs
- Apply materialization in Renderer when caching FunctionElement props
- Add unit tests for the utility function
- Add regression test for keyed list with child state changes
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a selective re-rendering optimization for the deephaven.ui rendering system. Previously, when any component's state changed, the entire component tree would re-render. This optimization introduces dirty tracking so that only components with state changes (and their descendants) re-render, significantly improving performance.

Changes:

  • Added dirty tracking infrastructure to RenderContext (_is_dirty, _has_dirty_descendant flags)
  • Implemented render output caching to reuse clean component renders
  • Added materialize_lazy_iterators utility to handle lazy iterators in props
  • Comprehensive test coverage including unit tests, integration tests, and performance benchmarks

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
plugins/ui/src/deephaven/ui/_internal/RenderContext.py Added dirty tracking flags, parent context tracking, and _mark_dirty propagation logic
plugins/ui/src/deephaven/ui/renderer/Renderer.py Implemented selective re-rendering with caching, _render_children_only for clean parents with dirty descendants
plugins/ui/src/deephaven/ui/_internal/utils.py Added materialize_lazy_iterators function to convert map/generators to lists
plugins/ui/src/deephaven/ui/elements/BaseElement.py Applied materialize_lazy_iterators to children props to prevent iterator exhaustion
plugins/ui/src/deephaven/ui/_internal/init.py Exported materialize_lazy_iterators utility function
plugins/ui/test/deephaven/ui/test_renderer.py Added comprehensive tests for selective rendering including edge cases
plugins/ui/test/deephaven/ui/test_render.py Added tests for dirty tracking propagation and lifecycle
plugins/ui/test/deephaven/ui/test_utils.py Added tests for materialize_lazy_iterators with map and generators
plugins/ui/test/deephaven/ui/test_benchmark.py New file with performance benchmarks validating optimization goals
plugins/ui/docs/add-interactivity/render-cycle.md Updated documentation to explain selective re-rendering behavior
plans/optimize-render-cycle.md New comprehensive implementation plan and design documentation
AGENTS.md New developer guidelines for Python environment and testing

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +67 to +69
if isinstance(item, (map, GeneratorType)):
# Materialize and recursively process contents
return [materialize_lazy_iterators(x) for x in item]
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The materialize_lazy_iterators function only handles map and GeneratorType, but there are other lazy iterator types that could cause the same exhaustion issues (filter, zip, itertools objects like chain, cycle, etc.). If users pass these as props, they won't be materialized and could be exhausted on subsequent renders, leading to missing children or unexpected behavior.

Consider expanding the check to handle other common lazy iterator types, or use a more general approach like checking for collections.abc.Iterator (excluding strings and bytes).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we could just do a loop grabbing next(x) but I know we also had issues with sets in the past and would have issues with strings and bytes as Copilot pointed out. We could have a larger allowed list of Iterables in general though.
It would be nice if we had some sort of unsafe registration for custom Iterables so that we don't have to make any attempt at being exhaustive (which we couldn't be anyways), but this is getting into the weeds and would be a different ticket.
At the very least, don't know why we couldn't combine the first two checks here into one isinstance check

Comment on lines +144 to +149
# Render children without opening parent context
# This preserves parent's effects and state while updating children
if cached_props is not None:
rendered_props = _render_dict_in_open_context(cached_props, context)
else:
rendered_props = {}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential issue with child context lifecycle management in _render_children_only. When this function calls _render_dict_in_open_context without opening the parent context, it will traverse the props and call parent_context.get_child_context(). This appends keys to parent._collected_contexts, but since the parent context is never opened here, the lifecycle management in RenderContext.open() (which clears _collected_contexts and unmounts unused child contexts) won't run properly. This could lead to child contexts not being properly tracked or unmounted, potentially causing memory leaks or stale state issues.

Consider wrapping the _render_dict_in_open_context call in a minimal context management that handles _collected_contexts properly, or restructure to ensure proper lifecycle management of child contexts.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true and could cause some annoying to debug issues. I think we just need to clear the collected_contexts afterwards; these contexts shouldn't be changing here because the state hasn't changed. Hmm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also aren't re-rendering if the input props have changed, which is significant.

Comment on lines +67 to +69
if isinstance(item, (map, GeneratorType)):
# Materialize and recursively process contents
return [materialize_lazy_iterators(x) for x in item]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we could just do a loop grabbing next(x) but I know we also had issues with sets in the past and would have issues with strings and bytes as Copilot pointed out. We could have a larger allowed list of Iterables in general though.
It would be nice if we had some sort of unsafe registration for custom Iterables so that we don't have to make any attempt at being exhaustive (which we couldn't be anyways), but this is getting into the weeds and would be a different ticket.
At the very least, don't know why we couldn't combine the first two checks here into one isinstance check

@@ -0,0 +1,490 @@
"""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the idea of benchmarks but I wonder if it should be it's own thing rather than under tests.
I think this is fine for now, but if we were ever to consider more benchmarking a separate entry point would be great. Maybe something like the code coverage tool where we could see if our changes slowed down the code automatically.

@mofojed
Copy link
Member Author

mofojed commented Feb 10, 2026

Found a couple issues in this approach when I was digging in/testing that I'll need to think about a bit more. I'm going to close this one and do component memoization first: #1296
That one is opt-in, so if there's any unforeseen issues, we don't break everything.

@mofojed mofojed closed this Feb 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants