Skip to content

Cleanroom reimplementation of the renderer tree walking#49

Open
maartenbreddels wants to merge 2 commits into
masterfrom
cleanroom/reconciler
Open

Cleanroom reimplementation of the renderer tree walking#49
maartenbreddels wants to merge 2 commits into
masterfrom
cleanroom/reconciler

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

What

Adds a faster renderer as an opt-in, off-by-default variant. The default _RenderContext keeps the original tree-walking unchanged (byte-for-byte with master); a _RenderContextFast(_RenderContext) subclass overrides only the tree-walking methods (_render, _reconsolidate, _remove_element, _visit_children, _visit_children_values). Select it with the env var REACTON_FAST=1.

CI runs the full suite against both renderers (a reacton-fast: ["0", "1"] matrix dimension on every Python version), so the two stay behavior-identical.

Why

With the default renderer, any state update pays a full tree walk — a single-leaf update, a memoized-subtree "skip", and a full force_update all cost the same ~14ms on a 300-row tree — and stale-element removal is accidentally quadratic.

How (the fast variant)

  • State setters mark needs_render_descendant up the parent chain, so a render pass only descends into subtrees that can contain work. A component subtree whose element is identical to the previous render, fully reconciled, and free of dirty/excepted contexts is skipped in both phases and keeps its previous widgets (clean_subtree).
  • force_update()/update()/first render walk fully (rc._walk_all).
  • Stale-element removal runs once per component context instead of per element.
  • Widget updates are skipped when an identical element reconciles to identical child widgets.
  • Side-effect (Layout/Style) widget tracking moved to ipywidgets' on_widget_constructed hook (shared by both renderers) instead of a per-creation global-dict diff that was O(live widgets).

Results (300-row tree, REACTON_FAST=1 vs default)

scenario default fast speedup
single leaf update 13.7 ms 0.62 ms 22x
memoized subtree skip 14.4 ms 1.0 ms 14x
root update / list reorder / force_update ~14–15 ms ~6–7 ms ~2.1x
initial render 223 ms 191 ms 1.2x

Harness (bench.py/compare.py), result JSONs, the full renderer contract both implementations satisfy, the deliberate test-invisible deltas, and known issues found during the work are documented in benchmarks/README.md.

Verification

  • Default renderer: pytest reacton/ --ignore=reacton/generate_test.py167 passed, 4 skipped.
  • Fast renderer: REACTON_FAST=1 pytest …same (167 passed, 4 skipped).
  • The default _RenderContext's five tree-walking methods are byte-for-byte identical to master (AST-verified), so the default path carries zero behavioral risk; the fast path is exercised entirely by the opt-in CI matrix dimension.

🤖 Generated with Claude Code

maartenbreddels and others added 2 commits June 15, 2026 17:51
The default renderer is unchanged: _RenderContext keeps the original
tree-walking (_render/_reconsolidate/_remove_element/_visit_children*),
byte-for-byte. A _RenderContextFast(_RenderContext) subclass overrides only
those methods with a reimplementation that does work proportional to what
changed instead of a full tree walk:

- state setters mark needs_render_descendant up the parent chain, so a render
  pass only descends into subtrees that can contain work; an identical,
  fully-reconciled, exception-free subtree is skipped in both phases
  (clean_subtree) and keeps its widgets;
- force_update/update/first render walk fully (rc._walk_all);
- stale-element removal runs once per context instead of an O(n) set-diff per
  element;
- widget updates are skipped when an identical element reconciles to identical
  child widgets.

Selected with REACTON_FAST=1 (via _render_context_class()); off by default.
Side-effect (Layout/Style) widget tracking also moved to ipywidgets'
on_widget_constructed hook (shared by both renderers) instead of a per-creation
global-dict diff that was O(live widgets).

benchmarks/ holds the harness, baseline-vs-fast result JSONs, and README.md
documenting the design, the renderer contract both implementations satisfy, the
deliberate test-invisible deltas, and known issues found during the work. On a
300-row tree the fast renderer takes a single-leaf update from 13.7ms to 0.62ms
(22x) and a memoized-subtree skip from 14.4ms to 1.0ms (14x), behavior-identical
to the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a reacton-fast: ["0", "1"] matrix dimension so the suite runs with the
default renderer and with the opt-in fast one (REACTON_FAST=1) on every Python
version, keeping the two behavior-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant