Completed:
- Phase 1: Dirty tracking infrastructure (committed as b42007a6)
- Phase 2: Cache rendered output
- Phase 3: Selective re-rendering
- Phase 4: Handle edge cases (key-based reconciliation, effect behavior with selective re-rendering)
- Performance benchmarks (see results below)
Benchmark Results:
| Metric | Result |
|---|---|
| Render speedup (selective vs full) | 38-44x faster |
| Re-render rate (111 component tree, leaf state change) | 0.90% (<10% target achieved) |
| Deep nesting (20 levels, leaf state change) | Only 1 component re-renders |
| Sibling isolation | Verified - siblings don't re-render |
Key Implementation Notes:
- Caching optimization only applies to
FunctionElement(components with@ui.component), not toBaseElement. This is becauseBaseElementprops are determined at construction time, not by hooks/state. - When a component is clean but has dirty descendants, we re-render children WITHOUT opening the parent's context. This preserves the parent's effects (they don't run again).
- Effects with no dependencies will NOT run every render if the component is clean - they only run when the component is dirty. This is a behavior change from before but is more efficient.
- Current limitation: Caching is based on context dirty state, not props. Children with clean contexts return cached values even if their props changed. This is similar to having
React.memo()everywhere but without props comparison. Future work: props-based memoization.
Key Components:
ElementMessageStream(ElementMessageStream.py) - Orchestrates rendering and client communicationRenderer(Renderer.py) - Recursively renders the component tree from root to leavesRenderContext(RenderContext.py) - Maintains state for each component, with parent-child hierarchyFunctionElement(FunctionElement.py) - Wraps user component functions decorated with@ui.component
Current Flow:
- State change via
use_statesetter → callscontext.queue_render() queue_render()→ calls_on_queue_rendercallback (shared across all contexts)_on_queue_render→ eventually callsElementMessageStream._queue_render()_queue_render()→ re-renders entire tree from root viaself._renderer.render(self._element)
Root Cause: All RenderContext instances share the same _on_change and _on_queue_render callbacks that point to the root stream. When any context's state changes, it triggers a full tree re-render.
Goal: Each RenderContext tracks whether it (or any descendant) needs re-rendering.
# In RenderContext.__init__
self._is_dirty: bool = False
self._has_dirty_descendant: bool = False
self._parent_context: Optional[RenderContext] = Nonedef get_child_context(self, key: ContextKey) -> "RenderContext":
if key not in self._children_context:
child_context = RenderContext(
self._on_change,
self._on_queue_render,
parent=self, # NEW: track parent
)
self._children_context[key] = child_context
self._collected_contexts.append(key)
return self._children_context[key]def set_state(self, key: StateKey, value: T | UpdaterFunction[T]) -> None:
if key not in self._state:
raise KeyError(f"Key {key} not initialized")
self._mark_dirty() # NEW: mark this context as dirty
def update_state():
if callable(value):
old_value = self._state[key].value
new_value = _value_or_call(partial(value, old_value))
else:
new_value = _value_or_call(value)
self._state[key] = new_value
self._on_change(update_state)
def _mark_dirty(self) -> None:
"""Mark this context as dirty and propagate to ancestors."""
self._is_dirty = True
parent = self._parent_context
while parent is not None:
if parent._has_dirty_descendant:
break # Already marked, ancestors are too
parent._has_dirty_descendant = True
parent = parent._parent_contextGoal: Store the last rendered output for each component to reuse when not dirty.
# In RenderContext
self._cached_rendered_node: Optional[RenderedNode] = NoneThe cache stores the last RenderedNode returned by this component. When the component (and its descendants) are clean, we can return this cached value directly without re-executing the component function.
Goal: Skip re-rendering components that haven't changed.
def _render_element(element: Element, context: RenderContext) -> RenderedNode:
"""Render an Element, potentially reusing cached output."""
# Check if we can skip rendering
if context._cached_rendered_node is not None:
if not context._is_dirty and not context._has_dirty_descendant:
# Component and descendants are clean, reuse cache
return context._cached_rendered_node
if not context._is_dirty and context._has_dirty_descendant:
# This component is clean but has dirty descendants
# Re-render children only, not this component's function
return _render_children_only(context._cached_rendered_node, context)
# Full re-render needed
with context.open():
props = element.render(context)
props = _render_dict_in_open_context(props, context)
# Clear dirty flags after successful render
context._is_dirty = False
context._has_dirty_descendant = False
rendered = RenderedNode(element.name, props)
context._cached_rendered_node = rendered
return rendereddef _render_children_only(context: RenderContext) -> RenderedNode:
"""Re-render only the children of a component, reusing the parent's cached props.
IMPORTANT: We do NOT open the parent context here. This preserves the parent's
effects - they won't be re-run. We just iterate over cached props and render
any child Elements (which will open their own contexts).
"""
cached_node = context._cached_rendered_node
cached_props = context._cached_props # Pre-rendered props with Elements
# Render children without opening parent context
rendered_props = _render_props_without_opening_context(cached_props, context)
# Clear the dirty descendant flag
context._has_dirty_descendant = False
rendered = RenderedNode(cached_node.name, rendered_props)
context._cached_rendered_node = rendered
return renderedWhen a component's state changes:
- That component is marked dirty
- When that component re-renders, all of its children re-render (because calling the component function creates new child Element instances)
When a component is clean but has a dirty descendant:
- The component's function is NOT re-called (use
_render_children_only) - We traverse into the cached output to find and re-render only the dirty descendants
This matches the behavior described in Phase 3 - the key insight is that _render_children_only traverses into cached children without re-calling the parent's function.
Future optimization (not in this change): Props-based memoization could skip re-rendering a child component even when its parent re-renders, if the child's props haven't changed (similar to React.memo()). This would require shallow comparison of props passed to child components.
Ensure components with different keys don't reuse each other's state (already handled by get_child_context(key)).
Important behavior change: When a component is clean (not dirty) but has dirty descendants:
- The component's function is NOT re-called
- The component's effects do NOT run (including effects with no dependencies)
- Only the dirty descendant's effects run
This means effects with no dependencies will only run when the component itself is dirty (has a state change), not on every render cycle. This is more efficient but is a change from the original behavior.
If an effect must truly run every render, the component should ensure it has state that changes, or the effect should be moved to a child component that re-renders more frequently.
Currently, the full document is diffed. With cached nodes, we can potentially generate more targeted patches.
The existing generate_patch using JSON Patch (RFC 6902) should work well since unchanged subtrees will produce identical JSON, resulting in minimal patches.
| Step | Task | Effort | Risk |
|---|---|---|---|
| 1 | Add _is_dirty and _has_dirty_descendant to RenderContext |
Low | Low |
| 2 | Add _parent_context tracking |
Low | Low |
| 3 | Implement _mark_dirty() propagation |
Low | Low |
| 4 | Add _cached_rendered_node storage |
Low | Low |
| 5 | Modify _render_element for conditional re-render |
Medium | Medium |
| 6 | Add _render_children_only helper |
Medium | Medium |
| 7 | Update tests for new behavior | Medium | Low |
| 8 | Performance benchmarking | Medium | Low |
Future work (not in this change):
- Props-based memoization (skip child re-render if props unchanged, like
React.memo())
React Fiber allows interruptible rendering and prioritization. This would be a major rewrite and is likely overkill for the Python server-side rendering model where we're not blocking a UI thread.
Only diff the output of changed components. This is essentially what we're proposing but at the RenderedNode level rather than DOM level.
Fine-grained reactivity where only the exact expressions that depend on changed state re-run. This would require a fundamental change to how use_state works and is not backwards compatible.
| Risk | Impact | Mitigation |
|---|---|---|
| Stale renders if dirty tracking is incorrect | High - incorrect UI | Comprehensive test coverage; fallback to full re-render on mismatch |
| Memory increase from caching | Medium | Cache only the rendered node per component; clear on unmount |
| Complexity increase | Medium | Clear documentation; maintain full re-render as debug mode |
- Unit tests: Verify dirty flag propagation in
RenderContext - Integration tests: Verify only expected components re-render
- Regression tests: All existing tests must pass
- Performance tests: Measure render time with large component trees where only leaf state changes
- Render count reduction: Measure number of component function invocations per state change
- Time to patch: Measure time from state change to document patch sent
- Benchmark: Create a test with 100+ components, change leaf state, verify <10% components re-render