perf: make fast-renderer updates 10-60x cheaper and fix a state memory leak - #58
Draft
maartenbreddels wants to merge 25 commits into
Draft
maartenbreddels wants to merge 25 commits into
maartenbreddels wants to merge 25 commits into
Conversation
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>
This was referenced Sep 28, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
childrenagain on every container, because_values_identicalcompared widgets with elements. So the cost grew with the size of the page, not with what changed.What
rc._rerender_needed_reasonsuntil the render context closed. Now only the last two are kept, which is all the error message ever reads.ExitStackComponentContextclassuse_effectcompares its dependencies when it is calleduse_eventregisters its handler when the widget is made, with no effect and noget_widgetNumbers
reacton's own time (render time with cheap stand-in widgets, minus the time in component bodies). Fast renderer, against
master, same interleaved run:Real solara with 1024
solara.Button(real kernel, comm and json), a click next to them: 3.21 ms → 0.10 ms.Behavior changes
use_statesetter is the same object on every render. Before, setters of two renders already compared equal.use_event:Tests
pytest reacton/withREACTON_FAST=0and=1: 236 and 239 passed.test_renderers_agree_on_random_updatesdrives 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.master, in both renderers.🤖 Generated with Claude Code