Repository navigation
Fix element cleanup after failed rebuilds and lifecycle callbacks - #1
Merged
ClaudiaX84 merged 8 commits intoOct 28, 2026
Merged
Conversation
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>
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.
One commit per defect, all in
packages/flutter/lib/src/widgets/framework.dart,with tests in
packages/flutter/test/widgets/framework_test.dart. Each defectwas 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 —
d9f8b11d4eaWhen
updateChildthrew insideComponentElement.performRebuild, the oldchild was replaced by an
ErrorWidgetafter_child?.deactivate()(fromflutter#173148, unchanged on upstream master). Its descendants stayed
active, out of the tree, never unmounted.
Fix: the old child goes through
deactivateChild(deactivated, unmounted atthe end of the frame). If it was already deactivated by
updateChild(e.g. itsreplacement failed to mount), it is left to the inactive list.
2. Failing
updateChildren: new children stayed active —41bd904bbf5When
Element.updateChildrenthrew part-way, callers kept their old childlist. 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),updateChildrendeactivates the children it placed in the new list that werenot in the old one. Children from the old list stay attached. A caller may
retry the update with that list:
_MultiChildComponentElement(ViewCollection,ViewAnchor) calls
updateChildrenfromperformRebuildwithout catching, andkeeps 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
disposestopped the unmount of the other inactive elements —d12f9c1e1a9_InactiveElements._unmountAllwalked the inactive elements with a plainrecursion. The first
unmountthat threw (typicallyState.dispose) abortedthe 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
disposealready ran). The first error is rethrown withits original stack, so
finalizeTreereports it as before. Later errors arereported right after it, once the build owner is no longer locked.
4. Failed elements kept healthy descendants alive —
98ef627acb3The cleanup in (1)/(2) marked elements
failedinstead of unmounting them intwo cases: a partly updated subtree, and a
deactivatethat threw during thecleanup. Failed elements no longer rebuild, but they are never unmounted:
no
dispose, listeners stay registered, render objects are not released, evenfor elements unrelated to the failure.
Fix:
failed after a
deactivatethrew (_deactivateFailedSubtreeRecursively),only recurse into children still attached to their parent
(
child._parent == element). In normal operation this always holds(
_unmountasserted it). Children that a failed update had already detachedand handed to the list are skipped, so partly updated subtrees go through
deactivateChildlike any other. Without the second filter, such a child wasdeactivated twice and marked failed while in the inactive list (debug: an
assertion in
_unmountwhile finalizing).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.
its render objects are still in the render tree.
5. Failed elements kept their GlobalKeys —
0aacdcbd92cFound 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
deactivateduring a normal removal —134ac2492c3Found while reviewing (4). When a
State.deactivatethrew while an element washanded to the inactive list,
_deactivateRecursivelystopped there andrethrew. 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.addruns the salvagefrom (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 —
5dec1baad41A failure from
debugDeactivatedcan leave the subtree inactive without markingit 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
deactivateper healthy elementdisposeper healthy elementdeactivatethrows during the cleanup (4, 5)deactivatethrows (4)disposethrows while finalizing (3)deactivatethrows during a removal (6)"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
updateChildcan leave aparentless 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
framework_test.dart): 85/85 after the review follow-up,with 21 added tests. Both duplicate-salvage regressions failed on
134ac2492c3before 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_probe_main.dart, output inrelease_probe_output.txt) runs the scenarios above with the real binding,built with
flutter build macos --releaseagainst this branch. The table'scounts were observed there.
release. Without the attached-child filter, a detached child got a second
deactivate()(M2). Without the detach check, render objects still in thetree were disposed (M3).
134ac2492c3.flutter analyzeon the changed files: no issues.dart format: no changes.flutter test test/widgets: 7024 passed, 24 failed. The 24 are golden/visualtests (
text_golden14,shadow5,overscroll_stretch_indicator2,box_decoration,image_filter,shader_mask1 each), with the same per-filecounts on the base. The suite is not green locally.
flutter test test/material test/cupertino(before the final fix): 9780 passed, 39 failed. The 39failing 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 thechanged 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-reviewskill, by theauthor. A final pass over the whole diff found and fixed two issues, folded
into the commits that introduced them:
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).
_deactivateRecursivelyhad replaced thestatic 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):
_deactivateFailedSubtreeRecursivelystill walked the stale childlist of a partly updated element. If that element (or an ancestor) then threw
in
deactivate, a child already handed to the inactive list was deactivateda 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 analyzeandframework_test.dartin its own state.Context
Found while investigating Superlist's SUPERLIST-FRONTEND-27QX
(
DesktopDragDetector.build→MediaQuery.of→ null check). These commits fixframework defects reproduced in tests. It has not been confirmed that any of
them is the origin of those production events.