Skip to content

Fix element cleanup after failed rebuilds and lifecycle callbacks - #1

Merged
ClaudiaX84 merged 8 commits into
macos_no_codesign_3_44_8from
fix/failed-rebuild-subtree-cleanup
Oct 28, 2026
Merged

ClaudiaX84 merged 8 commits into
macos_no_codesign_3_44_8from
fix/failed-rebuild-subtree-cleanup

Conversation

@ClaudiaX84

@ClaudiaX84 ClaudiaX84 commented Oct 27, 2026 •

Copy link
Copy Markdown

One commit per defect, all in packages/flutter/lib/src/widgets/framework.dart,
with tests in packages/flutter/test/widgets/framework_test.dart. Each defect
was reproduced by a test that fails on the commit before its fix.

They share one theme: when a lifecycle callback or render-tree operation
throws, the framework's best-effort cleanup could leave elements active
outside the tree
(they keep rebuilding against ancestors that may be
unmounted) or never unmounted (States not disposed, listeners and
GlobalKeys kept).

Bugs and fixes

1. Failed rebuild: only the root of the old child was deactivated — d9f8b11d4ea

When updateChild threw inside ComponentElement.performRebuild, the old
child was replaced by an ErrorWidget after _child?.deactivate() (from
flutter#173148, unchanged on upstream master). Its descendants stayed
active, out of the tree, never unmounted.

Fix: the old child goes through deactivateChild (deactivated, unmounted at
the end of the frame). If it was already deactivated by updateChild (e.g. its
replacement failed to mount), it is left to the inactive list.

2. Failing updateChildren: new children stayed active — 41bd904bbf5

When Element.updateChildren threw part-way, callers kept their old child
list. Children inflated before the failure, or moved in through a GlobalKey,
were in neither list and could not be reached by visitChildren.

Fix: before rethrowing the original error (rethrow, stack preserved),
updateChildren deactivates the children it placed in the new list that were
not in the old one. Children from the old list stay attached. A caller may
retry the update with that list: _MultiChildComponentElement (ViewCollection,
ViewAnchor) calls updateChildren from performRebuild without catching, and
keeps the list for its next rebuild. Otherwise the caller's own cleanup reaches
them. Children moved out through a GlobalKey are not in the new list and
stay where they went. A child moved in through a GlobalKey is unmounted at
the end of the frame unless something retakes it, as for any removed child.

3. A throwing dispose stopped the unmount of the other inactive elements — d12f9c1e1a9

_InactiveElements._unmountAll walked the inactive elements with a plain
recursion. The first unmount that threw (typically State.dispose) aborted
the pass. The remaining children and ancestors of that element, and every other
inactive element, had already been removed from the list and were never
unmounted.

Fix: the pass continues after a failed unmount. The element that threw is not
unmounted again (its dispose already ran). The first error is rethrown with
its original stack, so finalizeTree reports it as before. Later errors are
reported right after it, once the build owner is no longer locked.

4. Failed elements kept healthy descendants alive — 98ef627acb3

The cleanup in (1)/(2) marked elements failed instead of unmounting them in
two cases: a partly updated subtree, and a deactivate that threw during the
cleanup. Failed elements no longer rebuild, but they are never unmounted:
no dispose, listeners stay registered, render objects are not released, even
for elements unrelated to the failure.

Fix:

  • The inactive elements list, and the upstream walk that marks a subtree
    failed after a deactivate threw (_deactivateFailedSubtreeRecursively),
    only recurse into children still attached to their parent
    (child._parent == element). In normal operation this always holds
    (_unmount asserted it). Children that a failed update had already detached
    and handed to the list are skipped, so partly updated subtrees go through
    deactivateChild like any other. Without the second filter, such a child was
    deactivated twice and marked failed while in the inactive list (debug: an
    assertion in _unmount while finalizing).
  • If deactivating the subtree throws, every subtree that can still be unmounted
    is handed to the inactive list. The first deactivation failure goes through the upstream failed-subtree
    walk: that throwing element, its descendants and its ancestors stay failed.
    Later deactivation failures reached during salvage are suppressed. Those
    throwing elements and their ancestors stay failed, but their children are
    still visited and eligible descendants can be unmounted.
  • Nothing is unmounted if detaching the subtree's render object failed, because
    its render objects are still in the render tree.

5. Failed elements kept their GlobalKeys — 0aacdcbd92c

Found while reviewing (4). A failed element is never unmounted, so its
GlobalKey stayed registered. When the same widget was built again (typically
the next frame, once the error was gone), the framework tried to retake the
failed element: debug reported a duplicate GlobalKey and the subtree did not
come back. Fix: the cleanup unregisters the GlobalKey of each element it leaves
failed. This depends on (4).

6. A throwing deactivate during a normal removal — 134ac2492c3

Found while reviewing (4). When a State.deactivate threw while an element was
handed to the inactive list, _deactivateRecursively stopped there and
rethrew. The removed element was already detached but never added to the list.
Its deactivated descendants were never unmounted, and the ones not yet visited
stayed active out of the tree. Fix: _InactiveElements.add runs the salvage
from (4) before rethrowing. This depends on (4) and (5). The upstream semantics
for the throwing element are kept: it stays failed, not disposed.

7. Duplicate salvage after a debug deactivation failure — 5dec1baad41

A failure from debugDeactivated can leave the subtree inactive without marking
it failed. The inactive-list handler salvaged it, then the outer rebuild or
child-update catch repeated the salvage. The duplicate insertion asserted in
debug, masking the original error or preventing the healthy parent from
receiving its ErrorWidget.

The outer catches now salvage only children that are still active. Two
regressions reproduce both catch paths and verify the original error, an
ErrorWidget under the still-active parent, and exactly-once child teardown.

Guarantees verified

Scenario Rebuilds after the failure deactivate per healthy element dispose per healthy element Listeners of healthy elements GlobalKeys
Intact subtree, update throws (1) none 1 1 removed released, reusable
Partly updated subtree, new children (1, 2, 4) none 1 1 removed moved-out child kept active; moved-in child unmounted once
deactivate throws during the cleanup (4, 5) none 1 1 (outside the first thrower's subtree) removed released on failed elements, reusable next frame
Removed child of a partly updated subtree whose parent's deactivate throws (4) none 1 1 — —
Render object could not be detached (4) none 1 0 (by design) not removed —
dispose throws while finalizing (3) — 1 1 (thrower: 1 attempt) — released
deactivate throws during a removal (6) none 1 1 (outside the first thrower's subtree) removed released on failed elements

"Healthy element" in the table means an element outside the subtree marked
failed by the first deactivation error. With multiple throwing siblings,
descendants of a later thrower may still be unmounted; the later error is
suppressed by the best-effort salvage. The table does not promise identical
handling for the first and subsequent throwing callbacks.

Known limits

A render-object detach failure deeper inside updateChild can leave a
parentless descendant outside the inactive list. The attached-child filter
skips it; having no parent does not prove it was added to that list. This
pre-existing path can leave the descendant active and rebuilding and is not
fixed by this PR. The detach row in the table applies to the subtree directly
handled by salvage, not every nested detach failure.

Validation

  • Debug tests (framework_test.dart): 85/85 after the review follow-up,
    with 21 added tests. Both duplicate-salvage regressions failed on 134ac2492c3
    before the fix and passed afterwards. Mutation-checked: removing or simplifying each new branch makes at
    least one test fail. Two exceptions, M2 and M3, are only observable without
    asserts; see the release runs below.
  • Release, without asserts (before the final attached-child fix): a probe app (release_probe_main.dart, output in
    release_probe_output.txt) runs the scenarios above with the real binding,
    built with flutter build macos --release against this branch. The table's
    counts were observed there.
    • The two guards that debug cannot distinguish were checked by mutation in
      release. Without the attached-child filter, a detached child got a second
      deactivate() (M2). Without the detach check, render objects still in the
      tree were disposed (M3).
    • The release probe was not run on the base commit or rerun on final head
      134ac2492c3.
  • flutter analyze on the changed files: no issues. dart format: no changes.
  • flutter test test/widgets: 7024 passed, 24 failed. The 24 are golden/visual
    tests (text_golden 14, shadow 5, overscroll_stretch_indicator 2,
    box_decoration, image_filter, shader_mask 1 each), with the same per-file
    counts on the base. The suite is not green locally.
  • flutter test test/material test/cupertino (before the final fix): 9780 passed, 39 failed. The 39
    failing tests have exactly the same names on the base commit (mostly golden and
    ink effects). Not green locally either, no additional failure observed.
    These suites were not rerun after the final attached-child fix.

Review follow-up head: 5916bcb60ea. Analysis and formatting passed on the
changed framework/test files; all 7 Superlist drag lifecycle tests passed using
this SDK implementation. The broad widgets/material/cupertino suites and the
release probe above were not rerun for this follow-up.

Review rounds

Each commit was reviewed with the Engineering code-review skill, by the
author. A final pass over the whole diff found and fixed two issues, folded
into the commits that introduced them:

  • (2) The first version also deactivated children taken over from the old
    list. That broke callers that survive the error and retry with their old
    list (test: keeps the old children usable for a caller that retries the
    update
    ).
  • (4) The attached-child check in _deactivateRecursively had replaced the
    static tear-off with a closure, allocated for every element on every normal
    removal. It is back to a static visitor that skips children with no parent.

A further pass found one more issue, folded into (4):

  • (4) _deactivateFailedSubtreeRecursively still walked the stale child
    list of a partly updated element. If that element (or an ancestor) then threw
    in deactivate, a child already handed to the inactive list was deactivated
    a second time and marked failed while in the list. Test: deactivates a
    removed child once if its old parent then fails to deactivate
    . The same
    filter also applies to the upstream mount-failure cleanup
    (_deactivateFailedChildSilently), which uses that walk.

That pass confirmed that the first thrower's descendants are left failed.
The subsequent review clarified that descendants of later throwers reached
during salvage can be unmounted, as described above. An attempt to unmount
them broke the upstream test widget is not active if throw in deactivated
and was reverted.

Every commit passes flutter analyze and framework_test.dart in its own state.

Context

Found while investigating Superlist's SUPERLIST-FRONTEND-27QX
(DesktopDragDetector.build → MediaQuery.of → null check). These commits fix
framework defects reproduced in tests. It has not been confirmed that any of
them is the origin of those production events.

ClaudiaX84 and others added 6 commits October 27, 2026 13:06
ComponentElement.performRebuild replaced a child whose update threw with
an ErrorWidget but only called deactivate() on the old child itself. Its
descendants stayed active, out of the tree and never unmounted: their
States were never disposed, kept their listeners, and rebuilt on the next
notification against inherited elements that may have been unmounted
since (e.g. MediaQuery.of failing on Element.widget).

If the old subtree is still intact, it now goes through deactivateChild:
it is deactivated and unmounted at the end of the frame, so its States
are disposed and remove their listeners once.

If the subtree was only partly updated, or deactivating it throws, the
elements still attached are marked failed instead. They no longer
rebuild, but they are not disposed: listeners they registered stay
registered (their callbacks can no longer schedule a build) and their
resources are not released, as for the existing mount-failure path.
Descendants already handed to the inactive elements list or to a new
parent are left alone, and this cleanup does not deactivate any element
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When Element.updateChildren threw part-way (e.g. a later child failed to
mount), the caller kept its old child list. Children inflated before the
failure, or moved in through a GlobalKey, were in neither list: they
stayed active, unreachable through visitChildren, and the failed-rebuild
cleanup could not reach them. They kept rebuilding outside the tree.

Before rethrowing the original error, updateChildren now deactivates the
children it placed in the new list that were not in the old one, so they
go through the inactive elements list and are unmounted at the end of
the frame unless a GlobalKey retakes them. Children from the old list
stay attached: a caller may retry the update with that list (an element
that calls updateChildren from performRebuild without catching keeps it
for its next rebuild), and otherwise the caller's own cleanup reaches
them. Children moved out of the list through a GlobalKey are left alone.

If deactivating one of them throws, it is marked failed (it no longer
builds, but is not disposed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_InactiveElements._unmountAll unmounted the inactive elements with a
plain recursive walk. The first unmount that threw (typically a
State.dispose) aborted the whole pass: the remaining children and
ancestors of that element, and every other inactive element, had already
been taken out of the list and were never unmounted. Their States were
never disposed and their GlobalKeys stayed registered.

The pass now goes on after a failed unmount. The element that threw is
not unmounted again, so no dispose runs twice. The first error is
rethrown with its original stack trace, so finalizeTree reports it as
before; later errors are reported by finalizeTree after it, once the
build owner is no longer locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an update failed, the failed-rebuild cleanup marked elements
failed instead of unmounting them whenever the old subtree was partly
updated or deactivating it threw. Failed elements no longer rebuild, but
they were never unmounted: their States were not disposed, listeners
they registered stayed registered and their render objects were not
released, even for elements that had nothing to do with the failure.

- The inactive elements list, and the walk that marks a subtree failed
  after a deactivate threw, now only recurse into children still
  attached to their parent. Children that a failed update had already
  detached (and handed to the list themselves) are skipped, so a partly
  updated subtree goes through deactivateChild like any other and is
  unmounted once. Without this, such a child was deactivated a second
  time, and the failed walk marked it failed while it was in the
  inactive list.
- If deactivating the subtree throws, every subtree that can still be
  unmounted is handed to the inactive elements list and disposed at the
  end of the frame. The elements whose deactivate threw, their
  descendants (as upstream) and their ancestors are left failed, not
  disposed.
- Nothing is unmounted if detaching the subtree's render object failed,
  since its render objects are still in the render tree. In release,
  without this check, they were disposed while attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Elements that the failed-rebuild cleanup leaves in the failed state are
never unmounted, so they kept their GlobalKey registration. When the
same widget was built again, typically on the next frame once the error
was gone, the framework tried to retake the failed element for that key:
debug builds reported a duplicate GlobalKey and the subtree did not come
back.

The cleanup now unregisters the GlobalKey of every element it leaves
failed, so a later widget with that key creates a new element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a State.deactivate threw while an element was being handed to the
inactive elements list, the recursive deactivation stopped there: the
throwing element's subtree was marked failed and the error rethrown, but
the removed element had already been detached and was never added to
the list. Its descendants that had been deactivated were never
unmounted, and the ones not visited yet stayed active out of the tree
and kept rebuilding.

_InactiveElements.add now runs the same salvage as the failed-rebuild
cleanup before rethrowing: every part of the subtree that can be
unmounted is handed to the list and disposed at the end of the frame;
the element whose deactivate threw, and its ancestors, stay failed with
their GlobalKeys released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ClaudiaX84
ClaudiaX84 merged commit 0879593 into macos_no_codesign_3_44_8 Oct 28, 2026
1 check 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.

2 participants