Skip to content

Survive a widget/component slot flip inside one render call - #54

Merged
maartenbreddels merged 1 commit into
masterfrom
fix/nested-pass-widget-component-flip
Sep 3, 2026
Merged

Survive a widget/component slot flip inside one render call#54
maartenbreddels merged 1 commit into
masterfrom
fix/nested-pass-widget-component-flip

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

Why

Seen in production through solara's RoutingProvider: its get_nav_widget effect raised
KeyError: '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.

  1. A render call with a nested pass, where a positional slot holds 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.
  2. That assert, like any exception from reacton's own bookkeeping inside _render, 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.

What

  • _render, both renderers: a widget el_prev from 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_next is kept, it also holds contexts pre-created by state_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 on master for the right reason and pass with the fix, on the classic and the REACTON_FAST renderer:

  • the slot flip must render and keep the component's state
  • the flip must not strand the chained get_widget effect of a parent that re-executed in the aborted pass (the production chain)
  • a duplicate key aborting in the first pass, or in a nested pass, must not strand a chained effect either

The cost of the discard is one extra full render after an already-failed render.

🤖 Generated with Claude Code

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
maartenbreddels force-pushed the fix/nested-pass-widget-component-flip branch from 9bf4bd5 to 577bee0 Compare September 3, 2026 10:37
@maartenbreddels
maartenbreddels merged commit a1c07c6 into master Sep 3, 2026
24 checks passed
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.
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