Skip to content

perf: make fast-renderer updates 10-60x cheaper and fix a state memory leak - #58

Draft
maartenbreddels wants to merge 25 commits into
masterfrom
perf/render-updates
Draft

maartenbreddels wants to merge 25 commits into
masterfrom
perf/render-updates

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This makes the fast renderer (REACTON_FAST=1) about 10-60x cheaper on updates, and fixes a memory leak in both renderers. It is the first of two stacked PRs; the second (mounts) builds on it.

The default renderer keeps its behavior. It only gets the shared parts (about 1.05-1.15x).

Why

On a live page (for example a dashboard that updates while data streams in), most render cycles are updates. In the fast renderer an update still walked every sibling of the changed component, twice. It also set children again on every container, because _values_identical compared widgets with elements. So the cost grew with the size of the page, not with what changed.

What

  • Memory leak (both renderers): every state change kept its old and new value in rc._rerender_needed_reasons until the render context closed. Now only the last two are kept, which is all the error message ever reads.
  • Updates walk the context tree, not the element tree. Setters mark the dirty child in each parent, so a leaf update costs the same with 1 or with 1024 siblings.
  • Skip equal work:
    • containers whose children resolve to the same widgets are not set again
    • a child component with equal arguments is skipped without walking it
  • Mounts:
    • a one-pass mount for new subtrees
    • the implicit container only when a body returns None
    • no ExitStack
    • a plain ComponentContext class
    • widget creation without a lock or a per-widget wrapper
  • Hooks:
    • one stable setter per state key, as React does
    • use_effect compares its dependencies when it is called
    • use_event registers its handler when the widget is made, with no effect and no get_widget

Numbers

reacton's own time (render time with cheap stand-in widgets, minus the time in component bodies). Fast renderer, against master, same interleaved run:

scenario speedup
leaf update, 300 / 1024 siblings 21x / 58x
burst of 5 setters 20x
root update / keyed reorder 10.3x / 10.8x
list grows / shrinks by one ~10x
context change 8.9x
mount 2.9-4.0x

Real solara with 1024 solara.Button (real kernel, comm and json), a click next to them: 3.21 ms → 0.10 ms.

Behavior changes

  • A use_state setter is the same object on every render. Before, setters of two renders already compared equal.
  • use_event:
    • the handler moves to a new widget when the element gets one
    • a child can hook into its parent's existing widget
    • the handler is registered before effects run and removed after the effect cleanups
    • it adds no effect
  • The fast renderer creates the widgets of a new subtree during the render phase. Effects run at the same point as before. The rare cases (state set during the first render, an exception, a widget error) fall back to the two-phase walk.
  • Container widgets keep their resolved kwargs (one dict per container).

Tests

  • pytest reacton/ with REACTON_FAST=0 and =1: 236 and 239 passed.
  • test_renderers_agree_on_random_updates drives both renderers with the same random state changes on random trees and compares widgets and effect order after every step. A separate fuzz run (300 cases x 40 steps) passes.
  • The solara 1.62 unit suite gives the same failures as master, in both renderers.

🤖 Generated with Claude Code

maartenbreddels and others added 25 commits September 25, 2026 21:30
Every state change appended a RerenderReason with the previous and the
next state value to the render context, and nothing ever removed them.
A long-lived page therefore kept every old state value alive until the
page was closed: 1000 state sets kept 1000 old values (a dataframe,
a big list, ...) in memory, in both renderers.

Only the last two reasons are ever read, for the "too many renders"
error message, so a deque with maxlen=2 keeps the message and bounds
the memory. test_debug_infinite_loop checked the length of the list to
see that the loop ran; it now checks the error message and the
component render count instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fast renderer skips the update of a widget when its element is the
same object as last render and its kwargs resolve to the same values.
But it compared the resolved kwargs (elements replaced by widgets) with
the element kwargs (still elements), so for every container the compare
failed and its children were assigned again. A leaf update next to 300
rows re-set the children of the VBox (~170 us for 301 real widgets), a
root update re-set the children of all 300 HBoxes.

A container widget now remembers the kwargs it was last created or
updated with, and an unchanged element is compared against those. Only
widget elements whose kwargs hold elements keep them, so leaf widgets
cost no extra memory. Shared elements keep the old behavior.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a component re-renders, it makes new elements for its children.
A child whose arguments are equal does not re-render, but the fast
renderer still walked its whole subtree in both phases, only to find
nothing changed. A root update of a list of 300 rows walked all 300
rows (~20 us each).

Such a child now keeps its previous result like an unchanged element
already did, when nothing in its subtree is dirty. The new element
takes over the context (and get_widget finds its widget).

This needs a guard: when a widget at some key is replaced by a widget
of another type, reconciliation first removes the old subtree, with
the component contexts in it, so those contexts cannot be kept as they
are. The same problem already existed for a child element that is the
same object in every render (it comes from a parent's props): that
case raised a KeyError, test_replace_parent_same_child_element covers
it now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A component body that returns None gets an implicit container
(reacton's Fragment, or solara's Column) with the top level elements
it made. The fast renderer built that container for every body: an
extra element, entering it, and at exit a walk over all elements the
body made to find the top level ones. Most bodies return an element,
and then all of that was thrown away. In a mount of 1024 small
components this was about 16% of the time.

Now the body only records the elements it makes, and the container is
made (and fed the same elements) only when the body returns None. The
default renderer keeps the eager container.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Walking the arguments of widget elements is the hottest code of a
mount: every widget element is visited twice (render, reconcile) and
removed once, and each visit went through generic isinstance checks,
built a key string for every value (also for strings and numbers that
can never hold an element), and built new lists and dicts even in the
render and remove walks, which throw the result away.

The visitors now dispatch on the exact type, skip scalars without a
call, build keys only for values that can hold elements, and the
render and remove walks no longer build values. Subclasses of
list/tuple/dict take the old generic path, so the result is the same
(test_fast_child_visitors_match_default compares against the default
renderer's visitor).

Element.is_shared becomes a plain class attribute instead of a
property (read ~14k times per mount of 1024 buttons), with _shared kept
as an alias. _values_identical first compares lists of child widgets
in C (map(operator.is_)), which keeps the container compare of the
previous commit cheap for large lists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Element._create_widget did three things per widget that cost more
than the rest of reacton's widget bookkeeping (about 3 us per widget
on top of the constructor, in both renderers):

- it wrapped hold_trait_notifications with a new @contextmanager
  closure per widget, so a frontend update of several traits renders
  once. The same batching is now installed once per widget class; it
  finds the render context in widget._reacton_rc and leaves widgets
  that reacton did not create alone. Closing the widget drops the
  reference.
- it took a global lock, because the recording of widgets made as a
  side effect (Layout, Style) was a module global. The recording is
  now per thread, which also stops a render in another thread (another
  kernel) from recording its widgets as our orphans, even without the
  lock: a widget constructor that switched threads could do that.
- it asked the widget class for all its trait names on every create,
  update and close, to tell on_<name> listeners from on_<name> traits.
  Now only when a kwarg starts with "on_" at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fast renderer entered the component context managers (solara
registers one, plain reacton none) through a contextlib.ExitStack for
every component render. The ExitStack costs more than a typical
component body. With no managers the body now runs directly, with one
it runs in a plain with statement, and only with more managers an
ExitStack is used. The managers see the same enter/exit calls and the
same exceptions (test_component_context_managers_count).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One ComponentContext is made per component instance. As a dataclass
with 16 default factories it cost ~1.2 us to make (in both renderers),
and most of its containers (state_metadata, owns, context_listeners,
user_contexts, ...) are never used by a typical component. It is now a
plain class that makes the containers every rendered component uses,
and the rarely used ones on first access.

It also stops comparing contexts field by field: the dataclass __eq__
compared all fields (recursively, including the parent) whenever a
context was compared, e.g. as a use_effect dependency in use_context.
Contexts now compare (and hash) by identity. The constructor still
accepts the old field names as keyword arguments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A state change that re-renders one small component cost ~50 us, most
of it in code that runs for every render, independent of the tree:

- utils.equals did an import statement on every call (~0.5 us). It is
  called for every argument of a re-rendered child, and for every
  memo and effect dependency.
- utils.isinstance_lazy (every state change checks for a DataFrame)
  built lists for a single type name.
- render(), the setter and the hooks called the logger with arguments
  (and looped over the shared elements) also with logging off.
- every state change made a RerenderReason dataclass.

The log messages are the same when logging is on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a state change re-renders one component, every component on the
path from the root to it has a dirty descendant, and the fast renderer
walked the whole element tree of each of them, in both phases, to find
the dirty child. A leaf update next to 300 rows cost ~500 us (null
widgets), and next to 1024 buttons ~2 ms: the cost grew with the number
of siblings, not with the work.

A component that does not render again has the element tree of its
last reconciliation. For such a component the render phase now only
visits its dirty child contexts (setters record the dirty child in
each parent on their way up), in the order of the element tree, and
reconciliation only reconciles those. A leaf update is now the same
~15 us (null widgets) with 1 or with 1024 siblings.

The one thing that can change in the unchanged element tree is the
root widget of a dirty child (a new widget type, or a fragment with
other children). Then the widgets that hold it get their new kwargs,
using the resolved kwargs that containers keep (see "Do not re-set
container children that resolve to the same widgets"), in _rewire.
Shared elements, pending exceptions, forced updates and renders under
a replaced widget keep the full walk.

test_renderers_agree_on_random_updates drives both renderers with the
same random state changes on random trees (type flips, keys, shuffles,
fragments, component roots, effects that set state, caught
exceptions) and compares widgets and effect order after every step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The README documents how the fast renderer differs from the default
one and which contract both keep. Update it for the context tree walk,
the equal-arguments skip, the mount path changes and the widget
creation changes that both renderers share, and note a shared element
problem (in both renderers) that the random update test ran into.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A new component (a first render, a new list item, another component
type at a key) went through two walks: the render phase ran the bodies
and wrote the next element tree, and the reconciliation walked the same
elements again to create the widgets and move the bookkeeping. For a
new subtree nothing is compared, so the second walk was pure overhead.

The fast renderer now creates the widgets of a new subtree during the
render phase, children first, and reconciliation only moves the
bookkeeping and runs the effects in the same order as before
(_finish_mount). The render bookkeeping is still written, so a mount
can go back to the plain two phase state (_unmount): when a state
change during the render needs another pass, when a body raises, for
shared elements, and when a widget fails to be created. Then the
widgets of that pass are closed and reconciliation creates them again,
as before. This keeps the order of bodies, effects and exceptions.

The new tests pin those fallbacks (for both renderers), and the random
update test now also mounts subtrees that set state or raise in their
first render. A mount of 300 rows (null widgets) is ~1.3x faster.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every component body makes elements and calls hooks, and after the
renderer changes these are a large part of what reacton costs in a
mount: for 1024 solara-like buttons, the reacton calls made from the
bodies take about 3 ms, as much as all of reacton should cost.

- Element.__init__ set nine attributes; most have a fixed default
  that is never changed in place, so they are class attributes now
  (handlers, never read in reacton or solara, is made on first use).
  The render context lookup no longer goes through a function call.
- ComponentWidget/ComponentFunction only set mime_bundle when it is
  not the default.
- use_ref was use_memo with a closure, four calls deep; it is now one
  method on the render context with the same memo entry.
- use_state/use_effect/use_memo look up the render context directly,
  and use_state keys come from a table instead of str() per call.
- Effect keeps its defaults on the class.
- get_widget first looks in the current component, instead of first
  copying the children of the component into its search list.
- _arguments_changed skips utils.equals for identical values.
- ComponentContext makes state and resolved_kwargs right away: nearly
  every component uses them, and making them on first use costs more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a component renders again, every child component with equal
arguments is skipped in both phases, but it still went through the
generic code: _render called _render_component, which looked the
child up twice, compared the components with a Python __eq__, and
_reconsolidate entered a try/finally with the shared element checks.
That was ~2 us per child, the whole cost of a root update of 300 rows.

The skip check now runs in _render itself, with an identity check
before the component compare, and _reconsolidate handles a skipped
child before anything else. suppress_events() (every widget update)
is a plain context manager instead of a generator, and the stale key
check of a component only builds sets when there can be stale keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
close() removed the tree with _remove_element, the code that also
removes a single subtree during an update: per element it walked all
kwargs, deleted the key from four dicts, checked a set of asserts,
looked up the trait names to remove listeners, and afterwards every
component context was emptied field by field. For 1024 buttons that
was ~50 calls per component.

The fast renderer now closes the whole tree with its own walk: the
same order of effect cleanups and widget closes, the same exception
handling (test_close_order_same_in_both_renderers checks both against
the default renderer), but no bookkeeping for contexts that are
dropped right after. A widget element whose kwargs held no elements
(learned when its widget was made or updated) is not walked, and the
listener cleanup only runs when a kwarg starts with on_. Shared
elements keep the old path.

Emptying a context is now __dict__.clear(): every container is made
again, empty, when it is used after close (as before), and parent and
the elements fall back to the class default None. This is shared, so
the default renderer's close gets cheaper too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A component that renders again (a list owner, a context consumer)
walks its element tree in both phases, and for each child component
with equal arguments the walk called _render and later _reconsolidate
only to find that the child is skipped. With hundreds of children the
calls themselves were a large part of the update.

The two visitors used for the element tree of a widget now handle that
case inline: _render_children does what _render does for a component
element up to the skip, and _reconsolidate_children does the skip of
_reconsolidate. Every other element still goes through _render and
_reconsolidate. A root update of 300 rows is ~1.1x faster.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_arguments_changed runs for every child of a component that renders
again. Most children have either no positional or no keyword
arguments, and the length checks were four builtin calls; checking
emptiness first skips them. Elements made in a component body are
also appended directly to the plain ContainerAdder (other adders
still get .add()).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three things that ran for every child element in the update walks:

- the fast renderer recorded id(element) of every element it rendered
  (a set that only grows until close), only for a friendlier get_widget
  error. Every rendered element is already marked _key_frozen, which
  get_widget now uses for that message.
- a skipped child component was put in children_next in the render
  phase and taken out again in reconciliation; it is in children
  already, so reconciliation now looks there.
- the reconciliation visitor counted elements with one attribute
  update per element instead of one per list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A component that renders again compares the arguments of its child
elements and the dependencies of its hooks with utils.equals, which
went through seven isinstance checks before comparing two strings or
two lists. It now handles the exact builtin types first, with the same
result, and skips the recursive call for identical items.

The generated element factories make a ComponentWidget per element.
Keeping one per widget class (ComponentWidget.__new__) makes it
cheaper, and makes the component compares of the walks identity
checks instead of calls to __eq__.

isinstance_lazy (every state change asks for pandas.DataFrame) keeps
the class once it is imported, and _update_widget finds dropped
kwargs without building two sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The previous commit keeps one ComponentWidget per widget class in a
module level dict, which also kept every widget class alive forever.
Classes made at runtime (solara's hot reload makes new component_vue
and VuetifyTemplate subclasses on every reload, and user code can make
classes dynamically) could then never be freed.

The cache now has weak values: an entry goes away when no element uses
its ComponentWidget any more. Compares stay correct without the cache
(they try `is` first and fall back to ==). The lookup costs ~40 ns
more; the gain on the update scenarios stays within noise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first version of the one-pass mount still sent every component of
a new subtree through the generic _render_component (skip checks,
partial walks, hook count checks, pruning of the *_next dicts), wrote
the render bookkeeping, and moved it to the reconciled state in
reconciliation. A mount stayed ~2x above the floorc prototype (a fused
mount that keeps reacton's bookkeeping).

_mount_component and _mount_node now do the whole mount in one walk:
make the context, run the body with its hooks, resolve child elements
to widgets inline, create the widget (one recording of constructed
widgets per mount instead of one per widget), and write elements,
children, widgets and element_to_widget directly in their reconciled
form. Reconciliation only runs the effects (in the same order) and
hooks the root widget into its parent. The rare cases still go back
to the two phase walk: _unmount now turns the reconciled bookkeeping
of the pass's mounts into the render bookkeeping. context_managers is
a class default, so a context without component context managers does
not make an empty list on first use.

A mount of 1024 buttons or 300 rows (null widgets) is ~1.25x faster.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
use_state made a new setter closure in every render, for every state
key. React's setState is stable: the same function for the life of the
component. The setter is now made once per key and kept in the
component context (a new eq argument is still picked up).

This changes little in behavior: utils.equals compares functions by
code and closure cells, so the setters of two renders already compared
equal (a child that got a setter as an argument was already skipped,
and an effect with a setter in its dependencies did not run again).
What changes is the identity: the setter `is` the one of the previous
render, and the compare is an identity check instead of a walk over the
closure. In DEBUG mode the reason stack of a setter is that of its
first render.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every render made a new Effect object for every use_effect call, and
reconciliation then compared its dependencies with those of the effect
that ran, and dropped it when they were equal (most renders). The
compare now happens in use_effect itself: with equal dependencies no
Effect is made, and a new one from an earlier pass of the same render
call is dropped. Reconciliation runs the same effects in the same
order as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
use_event made a new Effect, a closure and a get_widget search on every
render, only to register the same handler again after the widget
existed. On a page of buttons this was about a fifth of the reacton
time of a mount. Now use_event keeps one handler object per hook (like
a stable setter) that calls the latest callback, and the renderer
registers it on the widget of the element when it creates or updates
that widget. A component element hands its handlers on to the element
its body returns, so the handler ends up on the widget of the
component, as get_widget found before. When the component of the hook
goes away, the handler is removed.

use_event is now one hook (a ref) for every kind of element. A reserved
no-op effect slot would also keep the hook counts equal, but measured
about 0.5 us per button, a third of the gain.

Behavior changes:
- When the element gets a new widget (another key or type, also the
  root widget of a component element), the handler moves to the new
  widget. Before, it stayed on the old one.
- A child component can hook into an element of its parent, also when
  that widget exists already. Before, get_widget raised KeyError.
- The handler is registered when the widget is made, before effects
  run, and it is removed after the effect cleanups of the component.
- use_event makes no effect: a component has one effect less per
  use_event call (the hook count check sees the same count every
  render, also when the element changes between a widget and a
  component element).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fast renderer made a new ContainerAdder for every component body,
only to record the elements the body makes; bodies of one render context
never nest, so one adder with a new list per body does the same job.
use_event went through get_render_context and a helper call for the
common case of a new element; now it reads the thread-local directly
and only takes the helper for an element that was rendered before.
Together about 5% of a mount of 1024 buttons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to Spencerx/Reacton that referenced this pull request Sep 28, 2026
The fast renderer was tested by running the test suite with
REACTON_FAST=1, but those tests were written for the default renderer
and rarely hit the cases the fast renderer skips: an element that is
the same object in the next render, in a subtree that did not change.

This test (from widgetti#58) renders random component trees
that change shape with their state, sets random state, and checks that
both renderers give the same widgets and run the same effects in the
same order after every step. Seed 9 fails on master: the fast renderer
raises a KeyError, fixed in the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to Spencerx/Reacton that referenced this pull request Sep 28, 2026
In the fast renderer, a child element that is the same object in every
render (it comes from outside, e.g. a prop, or it is the root element
of a component that did not render again) keeps its previous subtree
when nothing in it changed. But when the widget that holds it is
replaced by a widget of another type (a VBox becomes an HBox),
reconciliation first removes the old subtree, including the child's
component contexts. Keeping the child as it was then made the render
fail with a KeyError. The default renderer does not have this problem.

The fuzz test from the previous commit finds it: on master, 62 of 300
seeds (25 steps each) fail with this KeyError; with this fix all pass.

While the render phase walks the children of a widget that replaces a
widget of another type, the fast path for unchanged children is now
off, so the child is rendered again.

Taken from widgetti#58 (part of "Skip a child component with
equal arguments without walking it"), without the skip itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <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