Skip to content

Keep keyed children that move out of a replaced container - #65

Merged
maartenbreddels merged 3 commits into
masterfrom
fix/fast-keyed-move
Sep 29, 2026
Merged

maartenbreddels merged 3 commits into
masterfrom
fix/fast-keyed-move

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

Why

When reconciliation replaces a widget by one of another type (a VBox becomes an HBox), it first removes the old widget and all its children, by key. An explicit .key() is the same in any container of a component. So a keyed child that moved to a sibling container in the same render was removed too:

  • The sibling comes first: the moved child's widget is closed while it is in the tree. This happens in both renderers.
  • The sibling comes after: the fast renderer raises a KeyError. The default renderer survives.

This is the follow-up that the crossreview of #63 found.

What

Three commits:

  1. The fuzz test finds it. A Mover component keeps one child with an explicit key (made once with use_memo, so it is the same object in every render). On its own state, it moves the child between two sibling containers and flips their types. On master, 2 of the 12 seeds fail, and about 1 in 5 of 300 seeds.
  2. The fix, in both renderers. While a replaced element is being removed, its children with an explicit key that the new tree still uses are left alone. Reconciliation then updates them where the new tree has them.
    • Side effect: a keyed child that stays under the new container keeps its widget instead of getting a new one. Its state was already kept before.
    • Children without an explicit key are removed and made again, as before.
  3. A small fix from review: shared elements (.shared()) keep the old behavior, because their bookkeeping needs the removal.

Tests

  • Fuzz test, 1000 seeds with 25 steps each (local run): all pass. On master, about 1 in 5 seeds fail.
  • test_keyed_child_moves_out_of_replaced_container (both move orders): fails on master in both renderers.
  • test_shared_keyed_child_in_replaced_container: passes on master, and guards the review fix.
  • pytest reacton/ with REACTON_FAST=0 and =1: all pass.
  • Solara's unit suite: the same results as master, in both renderers.

Review

Crossreview by three reviewers (astra, opus, glm).

  • Checked and correct:
    • a key reused by another widget type or by a component
    • keyed children nested deeper
    • widget → component replacement
    • other component contexts are not touched
    • the keep never runs during close() or the stale-key sweeps
    • no leaked widgets or callbacks
    • the fuzz Mover is deterministic
  • Fixed from review (commit 3, confirmed by the driver): keyed shared elements under a replaced container raised "Element not reconsolidated", and old shared mappings stayed behind. Fixed by leaving shared elements to the old removal.
  • Not fixed here, an older bug of the same kind: a keyed child in a container that is removed, not replaced, for example VBox(children=[VBox(children=[child]) if s else child]). That removal runs through the stale-key sweep, which this fix does not change. It fails the same way on master, in both renderers. This is the next follow-up.

🤖 Generated with Claude Code

maartenbreddels and others added 3 commits September 29, 2026 11:46
Review of the previous fuzz test found a case it cannot reach: a child
with an explicit key that moves to a sibling container while the
container it leaves is replaced by one of another type. The random
trees now have a Mover component that does this with an element made
once (use_memo), so it is the same object in every render.

On master, 2 of the 12 seeds fail: the fast renderer has a closed
widget in the tree. Over 300 seeds, about 1 in 5 fail, some with a
KeyError in the fast renderer. Fixed in the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When reconciliation replaces a widget by one of another type (a VBox
becomes an HBox), it first removes the old widget with all its
children, by key. An explicit key is the same in any container of a
component, so a child with an explicit key that moved to a sibling
container in the same render was removed too:
- if the sibling was reconciled first, the moved child's widget was
  closed while it was in the tree (both renderers);
- if it came later, the fast renderer raised a KeyError, since it had
  kept the child's subtree as it was.

While a replaced element is removed, its children with an explicit key
that the new tree still uses are now left alone, and reconciliation
updates them where the new tree has them. A keyed child that stays
under the new container keeps its widget instead of getting a new one
(its state was kept already).

The fuzz test: on master 2 of the 12 seeds fail, and about 1 in 5 of
300; with this fix 1000 of 1000 pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review found that keeping a keyed child of a replaced container also
kept shared elements (.shared()), whose bookkeeping needs the removal:
the element stayed in _shared_elements, so reconciliation raised
"Element not reconsolidated", and old _shared_widgets entries piled up.
Shared elements now keep the old behavior.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels merged commit f26da05 into master Sep 29, 2026
24 checks passed
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