Survive a widget/component slot flip inside one render call - #54
Merged
Merged
Conversation
Two defects, seen in production through solara's RoutingProvider (the "Element VBox(...) was found to be in a previous render" KeyError from get_widget in its get_nav_widget effect): 1. When one render() call has a nested pass, a positional slot can hold a widget element in the first pass and a function component in the nested pass while that component still has its context from the last committed render. _render compared the component element against the widget element left in elements_next and tripped `assert not isinstance(el_prev.component, ComponentWidget)`. The state is reachable from ordinary app code: a write from another thread lands while a render is in flight, reacton defers it into a nested pass, and a conditional sibling that appears in one pass shifts the slots. Nothing was committed between the passes, so the component was never unmounted: render it, keep its state. 2. Any exception from reacton's own bookkeeping inside _render (that assert, a duplicate key, ...) escaped render() and left the tree half updated: components that re-executed in the aborted pass had already chained a new effect closure (previous_effect.next) over elements of that pass, but their root_element_next was never set. The next render that did not re-execute such a component ran the stale closure, and get_widget failed on an element that was never reconciled. Now an exception that escapes a render pass discards what the pass staged and marks every context dirty, so the next render starts from the last committed state. Reconciliation aborts are left alone: widgets created halfway need the normal removal path. Both fixes apply to the classic and the REACTON_FAST renderer; the four new tests fail on both without them.
maartenbreddels
force-pushed
the
fix/nested-pass-widget-component-flip
branch
from
September 3, 2026 10:37
9bf4bd5 to
577bee0
Compare
This was referenced Sep 3, 2026
maartenbreddels
added a commit
to widgetti/solara
that referenced
this pull request
Sep 3, 2026
…info Grotto hit a production incident (PENG-1257) where a building switch replaced the whole app by a traceback. The router effect had no dependencies, so it queued a widget lookup on every render. A render pass that aborted halfway left that closure with an element that was never reconciled, get_widget raised "was found to be in a previous render" from inside the effect, and the app was gone. reacton 1.10.3 (widgetti/reacton#54, #55) stops the aborted render, but the router should not depend on one effect to stay alive. The root element is always the same VBox at the same slot, so the Navigator widget is created once and reused: the lookup belongs in a run-once effect. get_widget(nav) reaches the widget directly on reacton 1.10.3, so the indirection over the parent container and the comment doubting it are gone. The shell formatted the traceback into the log message. Sentry groups on the message, so every uncaught exception of every app ended up in a single issue titled "Uncaught exception: Traceback (most recent call last):", hiding a TypeError, a reacton AssertionError and several ValueErrors behind one title. Passing the exception via exc_info keeps the message short and lets error trackers group per exception. The traceback string still goes to the frontend over the control socket unchanged.
maartenbreddels
added a commit
to widgetti/solara
that referenced
this pull request
Sep 3, 2026
…info (#1198) Grotto hit a production incident (PENG-1257) where a building switch replaced the whole app by a traceback. The router effect had no dependencies, so it queued a widget lookup on every render. A render pass that aborted halfway left that closure with an element that was never reconciled, get_widget raised "was found to be in a previous render" from inside the effect, and the app was gone. reacton 1.10.3 (widgetti/reacton#54, #55) stops the aborted render, but the router should not depend on one effect to stay alive. The root element is always the same VBox at the same slot, so the Navigator widget is created once and reused: the lookup belongs in a run-once effect. get_widget(nav) reaches the widget directly on reacton 1.10.3, so the indirection over the parent container and the comment doubting it are gone. The shell formatted the traceback into the log message. Sentry groups on the message, so every uncaught exception of every app ended up in a single issue titled "Uncaught exception: Traceback (most recent call last):", hiding a TypeError, a reacton AssertionError and several ValueErrors behind one title. Passing the exception via exc_info keeps the message short and lets error trackers group per exception. The traceback string still goes to the frontend over the control socket unchanged.
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.
Why
Seen in production through solara's
RoutingProvider: itsget_nav_widgeteffect raisedKeyError: 'Element VBox(children = ...) was found to be in a previous render, you may have used a stale element'and the whole app was replaced by the traceback. It took two reacton defects in a row.
_rendercompared the component element against the widget element left inelements_nextand trippedassert not isinstance(el_prev.component, ComponentWidget).The state is reachable from ordinary app code: a write from another thread lands while a render is in flight, reacton defers it into a nested pass, and a conditional sibling that appears in one pass shifts the slots.
_render, escapedrender()and left the tree half updated.Components that re-executed in the aborted pass had already chained a new effect closure (
previous_effect.next) over elements of that pass, but theirroot_element_nextwas never set.The next render that did not re-execute such a component ran the stale closure, and
get_widgetfailed on an element that was never reconciled.What
_render, both renderers: a widgetel_prevfrom an earlier pass of the same call means "render this component", not an assert. Nothing was committed between the passes, so the component was never unmounted and keeps its state.render(): an exception that escapes a render pass now discards what the pass staged (effect.next,root_element_next,elements_next, collected exceptions) and marks every context dirty, so the next render starts from the last committed state.children_nextis kept, it also holds contexts pre-created bystate_set(). Aborts inside reconciliation are left alone: widgets created halfway need the normal removal path.Tests
Four new tests in
core_test.py, all fail onmasterfor the right reason and pass with the fix, on the classic and theREACTON_FASTrenderer:get_widgeteffect of a parent that re-executed in the aborted pass (the production chain)The cost of the discard is one extra full render after an already-failed render.
🤖 Generated with Claude Code