Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1fc06d3
Keep only the last two rerender reasons
maartenbreddels Sep 25, 2026
4e10de0
Do not re-set container children that resolve to the same widgets
maartenbreddels Sep 25, 2026
c247581
Skip a child component with equal arguments without walking it
maartenbreddels Sep 25, 2026
dcbde64
Build the implicit container only when a component returns None
maartenbreddels Sep 25, 2026
b04ddb1
Make the fast renderer's child visitor cheaper
maartenbreddels Sep 25, 2026
46492e6
Create widgets without a lock and without a per-widget wrapper
maartenbreddels Sep 25, 2026
7c97a13
Enter component context managers without an ExitStack
maartenbreddels Sep 25, 2026
d1b8084
Make ComponentContext a plain class
maartenbreddels Sep 25, 2026
5828bd8
Lower the fixed cost of every render and state change
maartenbreddels Sep 25, 2026
957e11b
Walk the context tree for updates, not the element tree
maartenbreddels Sep 25, 2026
801de34
Describe the new fast renderer paths in the benchmark README
maartenbreddels Sep 25, 2026
3eea3d1
Mount new components in one pass
maartenbreddels Sep 25, 2026
5705ed0
Make element creation and the common hooks cheaper
maartenbreddels Sep 25, 2026
a13d3b9
Make the path of an unchanged child component leaner
maartenbreddels Sep 25, 2026
5e410ca
Close a whole tree without the bookkeeping of a partial removal
maartenbreddels Sep 25, 2026
ff11966
Skip unchanged child components without a call per child
maartenbreddels Sep 25, 2026
fde2b8e
Compare element arguments with fewer calls
maartenbreddels Sep 25, 2026
ae26b46
Do less per child in the walks of a component that renders again
maartenbreddels Sep 25, 2026
31972b4
Compare values and components with exact-type fast paths
maartenbreddels Sep 25, 2026
668989e
Do not keep widget classes alive through the shared ComponentWidget
maartenbreddels Sep 25, 2026
990da17
Mount a new subtree in one tight walk
maartenbreddels Sep 25, 2026
206d6c3
Keep one setter per state key
maartenbreddels Sep 25, 2026
8eb6602
Compare effect dependencies when use_effect is called
maartenbreddels Sep 25, 2026
7e8de01
Register use_event handlers when the widget is made
maartenbreddels Sep 25, 2026
7d801ee
Make the plumbing around a component body and use_event cheaper
maartenbreddels Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,22 @@ memoized-subtree "skip", and a full `force_update` all cost the same (~14ms on a
renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both:

- **Dirty-subtree skipping.** State setters mark `needs_render_descendant` up
the parent chain, so a render pass only descends into subtrees that can
the parent chain, and record the dirty child in every parent
(`dirty_children`), so a render pass only descends into subtrees that can
contain work. A component subtree whose element is identical to the previous
render (`el is el_prev`), is fully reconciled, and has no dirty/excepted
contexts is skipped in *both* phases and keeps its previous widgets
(`clean_subtree`).
render (`el is el_prev`), or a new element with equal arguments, is fully
reconciled, and has no dirty/excepted contexts is skipped in *both* phases
and keeps its previous widgets (`clean_subtree`). Not while a widget above it
at the same key is replaced by another widget type: reconciliation removes
that whole subtree first.
- **Walking the context tree, not the element tree.** A component that does
not render again, but has a dirty descendant, has the element tree of its
last reconciliation. Its element tree is not walked; only its dirty child
contexts are (`partial`), in element order, in both phases. An update costs
work proportional to the depth of the dirty component, not to the number of
siblings. When the root widget of such a child changes (another widget type,
or a fragment with other children), the widgets holding it are updated
(`_rewire`). Shared elements and pending exceptions keep the full walk.
- **Forced full walks** (`force_update()`, `update()`, the first render) set
`rc._walk_all`, disabling skipping for that pass — faithful to the old
behavior.
Expand All @@ -52,19 +63,37 @@ renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both:
element.
- **Widget updates** are skipped when an identical element reconciles to
identical child widget objects (`_values_identical`), avoiding pointless
traitlets assignments.
traitlets assignments. Container widgets keep the kwargs they were last
created or updated with (`resolved_kwargs`) for this compare.
- **Mount path.** The implicit container is only built when a component body
returns `None`; component context managers are entered without an
`ExitStack`; the child visitors dispatch on exact types and skip scalars.
- **Side-effect ("orphan") widgets** (Layout/Style created during construction)
are tracked via ipywidgets' `on_widget_constructed` hook instead of diffing
the global widgets dict per creation — the old diff was O(live widgets) per
widget, so it degraded as an app grew.

Shared by both renderers (cheaper, same behavior): widget creation
(`Element._create_widget`) installs the render batching of
`hold_trait_notifications` once per widget class instead of a wrapper per
widget, records side-effect widgets per thread (no global lock), and only asks
for the trait names when a kwarg starts with `on_`; `ComponentContext` is a
plain class that makes its rarely used containers on first use;
`utils.equals`, the setter, the hooks and `render()` avoid work that is only
needed with logging on. Only the last two rerender reasons are kept (they held
every old state value).

## Renderer contract

What both renderers must preserve (derived from `core.py` + the test suite).
The fast renderer overrides only `_render`, `_reconsolidate`, `_remove_element`,
`_visit_children`, `_visit_children_values`; everything else (Element widget
The fast renderer overrides the tree walking (`_render`, `_reconsolidate`,
`_remove_element`, the child visitors, `_mark_dirty`, `_discard_aborted_pass`)
and adds `_render_component`, `_render_dirty_children`,
`_reconsolidate_partial`, `_rewire`; everything else (Element widget
create/update/close, hooks storage, `ComponentContext`, exception plumbing, the
render loop) is shared with the default renderer.
`test_renderers_agree_on_random_updates` drives both renderers with the same
random state changes and compares the widgets and the effect order.

**Phases** (inside one `rc.render()` call, under `thread_lock`, with `local.rc` set):

Expand Down Expand Up @@ -93,7 +122,8 @@ context raises `KeyError`. `el._key_frozen` is set once an element is rendered.
- `needs_render` = `context.needs_render` (set by setters/force) OR
`el._arguments_changed(el_prev)` OR `context.exceptions_children`. If false,
the body is *not* executed (component `render_count` stays put), the previous
`root_element` is reused, but it is still walked.
`root_element` is reused, but it is still walked (the fast renderer only walks
the dirty child contexts, see above).
- Body execution resets `state_index`/`effect_index`/`memo_index`,
`user_contexts={}`, `exception_handler=False`, `needs_render=False` before the
call; wraps in `context_managers` (the solara `ContextManager` hook) and the
Expand Down Expand Up @@ -159,6 +189,8 @@ contexts; `provide` notifies listeners only when the value changed.
assigned (the old code closed them mid-walk, so a closed widget could briefly
remain in a container's `children`).
- Per-element debug logging in the hot paths was dropped.
- Elements of a component that is not walked do not get their (test only)
`_render_count` incremented.

## Known issues worth revisiting (found during the rewrite, not fixed here)

Expand All @@ -172,6 +204,14 @@ contexts; `provide` notifies listeners only when the value changed.
likely needs a pandas-3 fix; the test environment pins `pandas<3` for now.
- `ComponentContext.owns` is dead — never written, only asserted empty in
`_remove_element`.
- **Shared elements in a component that does not render again** (both
renderers): when such a component is walked because a child is dirty, its
shared element (the same object as before) is added to
`_shared_elements_next` in the render phase, but reconciliation returns early
for an already reconciled shared element, so the render fails with
`RuntimeError: Element not reconsolidated`. Example: `App` returns
`VBox(children=[HBox(children=[shared, shared]), Child()])` and only `Child`
changes state.

## Where initial-render time goes

Expand Down
Loading
Loading