Skip to content

Keep keyed children when their wrapper container goes away - #70

Merged
maartenbreddels merged 2 commits into
masterfrom
fix/keyed-child-out-of-removed-container
Sep 29, 2026
Merged

maartenbreddels merged 2 commits into
masterfrom
fix/keyed-child-out-of-removed-container

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

Why

When a container is no longer in the new tree, the stale-key sweep removes it together with all its children, by key. An explicit .key() is the same in any container of a component. So a keyed child that is still in the tree at another level was removed too. For example:

child = Child().key("x")
w.VBox(children=[w.VBox(children=[child]) if wrapped else child])

When wrapped becomes False:

  • default renderer: crashes with AttributeError: 'NoneType' object has no attribute 'comm_id'
  • fast renderer: shows a closed widget in the tree, or leaves elements behind

This is the same kind of bug that #65 fixed for a container that is replaced by one of another type. The crossreview of #65 found this case, where the container is removed.

What

Two commits:

  1. The fuzz test finds it. An Unwrapper component puts a child with an explicit key inside 0, 1 or 2 wrapper containers, depending on its state. The child is sometimes the same object (made once with use_memo) and sometimes new. The keyed children that move (in Mover and Unwrapper) now have an effect, and the effect log at close() is compared too. On 1.10.4, 2 of the 12 seeds fail, and 73 of 300.
  2. The fix, in both renderers.

Visible change: a keyed child whose wrapper goes away now keeps its widget and its state.

Tests

  • Fuzz test, including effects and the close log, 1000 seeds with 25 steps each (a local run; the PR keeps 12): all pass. On 1.10.4, 73 of 300 fail.
  • test_keyed_child_out_of_removed_wrapper (child as the same object, and as a new object): fails on 1.10.4 in both renderers.
  • test_keyed_child_out_of_removed_wrapper_keeps_its_effects: the effect runs once and is cleaned up once, at close().
  • pytest reacton/ with REACTON_FAST=0 and =1: all pass.
  • Solara's unit suite: all 496 pass, in both renderers.

Review

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

  • Round 1: all three found the same bug in the first version. The fast renderer's early stale-effect cleanup still cleaned up the kept child's effects, so the child stayed mounted with dead effects. The fuzz test missed it, because the moved child had no effect. This was fixed with the rule above, and the fuzz test and a unit test now cover it.
  • Round 2: all three approve, with no new findings. They checked:
    • that self.context owns the removed element at each sweep
    • that used_keys is the right "still in the tree" signal
    • key reuse by another type
    • moves into and out of child components
    • a second render pass
    • error boundaries
    • that close() still cleans up everything
  • Not fixed here, older bugs: .shared() elements have several bugs, and they fail the same way on master.

🤖 Generated with Claude Code

maartenbreddels and others added 2 commits September 29, 2026 13:52
The previous fuzz extension moves a keyed child out of a container that
is replaced by one of another type. Review found the same problem when
the container is removed instead, e.g.
VBox(children=[VBox(children=[child]) if wrapped else child]): the
removal of the stale wrapper removes the child with it, although the
child is still in the tree.

The random trees now have an Unwrapper component that puts a child with
an explicit key inside 0, 1 or 2 wrapper containers, depending on its
state, with a child that is sometimes the same object (use_memo) and
sometimes new. The moved keyed children (of Mover and Unwrapper) now
have an effect, and the effects that run at close() are compared too,
so a kept child whose effect was cleaned up (or never is) shows up.

On master, 2 of the 12 seeds fail (73 of 300): the default renderer
raises AttributeError ('NoneType' object has no attribute 'comm_id'),
and the fast renderer leaves elements behind. Fixed in the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a container is no longer in the new tree, the stale-key sweep
removes it with all its children, by key. An explicit key is the same
in any container of a component, so a keyed child that is still in the
tree at another level, e.g. in
VBox(children=[VBox(children=[child]) if wrapped else child]), was
removed too: the default renderer raised AttributeError ('NoneType'
object has no attribute 'comm_id'), the fast renderer showed a closed
widget or left elements behind.

This is the same problem that the previous fix solved for a container
replaced by one of another type. The stale-key sweeps (both renderers,
and the fast renderer's root sweep) now remove through the same path,
renamed from _remove_replaced to _remove_outgoing: children with an
explicit key that the new tree still uses stay, and reconciliation
updates them where the new tree has them. The fast renderer cleans up
the effects of stale subtrees early (before new effects run); that
pass now also leaves the kept children alone, so their effects keep
running. Shared elements keep the old behavior, and close() still
removes everything.

The fuzz test: on master 73 of 300 seeds fail; with this fix 1000 of
1000 pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels merged commit 1da633c 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