From d9f8b11d4ea233a0d8a10cae7d25b101a98187bd Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 11:55:51 +0900 Subject: [PATCH 1/8] Unmount the old subtree when updating a ComponentElement's child throws 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 --- .../flutter/lib/src/widgets/framework.dart | 65 ++++- .../flutter/test/widgets/framework_test.dart | 236 ++++++++++++++++++ 2 files changed, 297 insertions(+), 4 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index c58fec102631d..8fadbeb2292f8 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -4644,6 +4644,47 @@ abstract class Element extends DiagnosticableTree implements BuildContext { }()); } + static bool _isActiveSubtree(Element element) { + if (element._lifecycleState != _ElementLifecycle.active) { + return false; + } + var isActive = true; + element.visitChildren((Element child) { + isActive = isActive && child._parent == element && _isActiveSubtree(child); + }); + return isActive; + } + + // Like _deactivateFailedChildSilently, but only deactivates the elements + // that are still active, and skips descendants no longer attached to their + // parent: those were already handed to the inactive elements list or to a + // new parent. + void _deactivateRemainingSubtreeSilently(Element child) { + try { + child._parent = null; + child.detachRenderObject(); + } catch (_) {} + void fail(Element element) { + if (element._lifecycleState == _ElementLifecycle.active) { + try { + element.deactivate(); + } catch (_) { + element._ensureDeactivated(); + } + } + element._lifecycleState = _ElementLifecycle.failed; + try { + element.visitChildren((Element descendant) { + if (descendant._parent == element) { + fail(descendant); + } + }); + } catch (_) {} + } + + fail(child); + } + void _deactivateFailedChildSilently(Element child) { try { child._parent = null; @@ -5851,10 +5892,26 @@ abstract class ComponentElement extends Element { ], ), ); - // _Make sure the old child subtree are deactivated and disposed. - try { - _child?.deactivate(); - } catch (_) {} + // Make sure the old child subtree no longer builds: an intact subtree is + // deactivated and unmounted (disposing its states). If updateChild + // already deactivated it before failing, it is no longer our child. A + // subtree that was only partly updated (e.g. some of its children were + // already deactivated) can't go through the inactive elements list + // without being unmounted twice, so it is only marked failed and is not + // disposed. + final Element? oldChild = _child; + _child = null; + if (oldChild != null && oldChild._parent == this) { + if (Element._isActiveSubtree(oldChild)) { + try { + deactivateChild(oldChild); + } catch (_) { + _deactivateRemainingSubtreeSilently(oldChild); + } + } else { + _deactivateRemainingSubtreeSilently(oldChild); + } + } _child = updateChild(null, built, slot); } } diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 8b474067a6ff6..be9a4a1c7b1a3 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2138,6 +2138,242 @@ The findRenderObject() method was called for the following element: expect(element.debugIsDefunct, true); }, ); + + testWidgets('old subtree is unmounted if updating the child of a ComponentElement throws', ( + WidgetTester tester, + ) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var builds = 0; + var disposals = 0; + Widget buildTree({required bool throwOnUpdate}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) => _ThrowOnUpdateWidget( + throwOnUpdate: throwOnUpdate, + child: StatefulWidgetSpy( + onDispose: (_) => disposals++, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + builds++; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(throwOnUpdate: false)); + final Element element = tester.element(find.byType(ListenableBuilder)); + expect(notifier.listenerCount, 1); + + await tester.pumpWidget(buildTree(throwOnUpdate: true)); + expect(tester.takeException(), isStateError); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(element.debugIsDefunct, true); + expect(disposals, 1); + expect(notifier.listenerCount, 0); + + builds = 0; + notifier.notify(); + await tester.pump(); + expect(builds, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('old subtree is unmounted once if mounting a replacement child throws', ( + WidgetTester tester, + ) async { + var disposals = 0; + await tester.pumpWidget( + Builder(builder: (_) => StatefulWidgetSpy(onDispose: (_) => disposals++)), + ); + final Element element = tester.element(find.byType(StatefulWidgetSpy)); + + await tester.pumpWidget(Builder(builder: (_) => const _ThrowOnMountWidget())); + + expect(tester.takeException(), isStateError); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(element.debugIsDefunct, true); + expect(disposals, 1); + }); + + testWidgets('partly updated old subtree is torn down once if updating it throws', ( + WidgetTester tester, + ) async { + var disposals = 0; + Widget buildTree(Widget middle) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => Stack(children: [const SizedBox(), middle, const SizedBox()]), + ), + ); + } + + await tester.pumpWidget(buildTree(StatefulWidgetSpy(onDispose: (_) => disposals++))); + final Element element = tester.element(find.byType(StatefulWidgetSpy)); + final Element stack = tester.element(find.byType(Stack)); + + await tester.pumpWidget(buildTree(const _ThrowOnMountWidget())); + + expect(tester.takeException(), isStateError); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(element.debugIsDefunct, true); + expect(disposals, 1); + expect(stack.debugIsActive, false); + + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'old subtree is torn down if deactivating it throws after updating it threw', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var throwOnDeactivate = false; + var firstDeactivations = 0; + var builds = 0; + Widget buildTree({required bool throwOnUpdate}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: throwOnUpdate, + child: Stack( + children: [ + StatefulWidgetSpy(onDeactivate: (_) => firstDeactivations++), + StatefulWidgetSpy( + onDeactivate: (_) { + if (throwOnDeactivate) { + throw StateError('kaboom'); + } + }, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + builds++; + return const SizedBox(); + }, + ), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(throwOnUpdate: false)); + final Element stack = tester.element(find.byType(Stack)); + final Element listenableBuilder = tester.element(find.byType(ListenableBuilder)); + + throwOnDeactivate = true; + await tester.pumpWidget(buildTree(throwOnUpdate: true)); + throwOnDeactivate = false; + + expect(tester.takeException(), isStateError); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(firstDeactivations, 1); + expect(stack.debugIsActive, false); + expect(listenableBuilder.debugIsActive, false); + + builds = 0; + notifier.notify(); + await tester.pump(); + expect(builds, 0); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('child moved out by a GlobalKey survives when its old parent fails to update', ( + WidgetTester tester, + ) async { + final GlobalKey key = GlobalKey(); + var disposals = 0; + final Widget keyed = KeyedSubtree( + key: key, + child: StatefulWidgetSpy(onDispose: (_) => disposals++), + ); + Widget buildTree({required bool moved}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + if (moved) keyed, + Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: moved, + child: moved ? const SizedBox() : keyed, + ), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(moved: false)); + final Element element = tester.element(find.byType(StatefulWidgetSpy)); + final Element oldParent = tester.element(find.byType(_ThrowOnUpdateWidget)); + + await tester.pumpWidget(buildTree(moved: true)); + + expect(tester.takeException(), isStateError); + expect(oldParent.debugIsDefunct, true); + expect(element.debugIsActive, true); + expect(tester.element(find.byType(StatefulWidgetSpy)), same(element)); + expect(disposals, 0); + }); +} + +class _ThrowOnMountWidget extends LeafRenderObjectWidget { + const _ThrowOnMountWidget(); + + @override + RenderObject createRenderObject(BuildContext context) { + throw StateError('kaboom'); + } +} + +class _ListenerCountingNotifier extends ChangeNotifier { + int listenerCount = 0; + + @override + void addListener(VoidCallback listener) { + listenerCount++; + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + listenerCount--; + super.removeListener(listener); + } + + void notify() => notifyListeners(); +} + +class _ThrowOnUpdateWidget extends SingleChildRenderObjectWidget { + const _ThrowOnUpdateWidget({required this.throwOnUpdate, super.child}); + + final bool throwOnUpdate; + + @override + RenderObject createRenderObject(BuildContext context) => RenderProxyBox(); + + @override + void updateRenderObject(BuildContext context, RenderProxyBox renderObject) { + if (throwOnUpdate) { + throw StateError('kaboom'); + } + } } class _TestInheritedElement extends InheritedElement { From 41bd904bbf5a330ee98cd7e114b9abbb08cb0281 Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 13:39:50 +0900 Subject: [PATCH 2/8] Deactivate the children of a failed updateChildren 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 --- .../flutter/lib/src/widgets/framework.dart | 43 +++- .../flutter/test/widgets/framework_test.dart | 233 ++++++++++++++++++ 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 8fadbeb2292f8..8028c132b09b6 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -4127,6 +4127,47 @@ abstract class Element extends DiagnosticableTree implements BuildContext { List newWidgets, { Set? forgottenChildren, List? slots, + }) { + final newChildren = List.filled(newWidgets.length, _NullElement.instance); + try { + return _updateChildren( + oldChildren, + newWidgets, + newChildren, + forgottenChildren: forgottenChildren, + slots: slots, + ); + } catch (_) { + _deactivateChildrenAfterFailedUpdate(oldChildren, newChildren); + rethrow; + } + } + + // The caller keeps its old child list when updateChildren throws, so children + // inflated (or moved here through a GlobalKey) before the failure would + // otherwise stay active without being reachable through visitChildren. + // Children from the old list stay attached: a caller may retry the update + // with that list, and otherwise its own cleanup reaches them. + void _deactivateChildrenAfterFailedUpdate(List oldChildren, List newChildren) { + final oldChildSet = Set.identity()..addAll(oldChildren); + for (final newChild in newChildren) { + if (newChild is _NullElement || oldChildSet.contains(newChild)) { + continue; + } + try { + deactivateChild(newChild); + } catch (_) { + _deactivateRemainingSubtreeSilently(newChild); + } + } + } + + List _updateChildren( + List oldChildren, + List newWidgets, + List newChildren, { + Set? forgottenChildren, + List? slots, }) { assert(slots == null || newWidgets.length == slots.length); @@ -4176,8 +4217,6 @@ abstract class Element extends DiagnosticableTree implements BuildContext { int newChildrenBottom = newWidgets.length - 1; int oldChildrenBottom = oldChildren.length - 1; - final newChildren = List.filled(newWidgets.length, _NullElement.instance); - Element? previousChild; // Update the top of the list. diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index be9a4a1c7b1a3..752838bc87c7d 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2331,6 +2331,239 @@ The findRenderObject() method was called for the following element: expect(tester.element(find.byType(StatefulWidgetSpy)), same(element)); expect(disposals, 0); }); + + group('updateChildren throwing after inflating new children', () { + Widget stackOf(List middle) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => Stack(children: [const SizedBox(), ...middle, const SizedBox()]), + ), + ); + } + + testWidgets('unmounts the new children once and reports the original error', ( + WidgetTester tester, + ) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var oldDisposals = 0; + var newDeactivations = 0; + var newDisposals = 0; + var builds = 0; + await tester.pumpWidget( + stackOf([StatefulWidgetSpy(onDispose: (_) => oldDisposals++)]), + ); + final Element oldElement = tester.element(find.byType(StatefulWidgetSpy)); + + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget( + stackOf([ + const SizedBox(width: 1), + StatefulWidgetSpy( + onDeactivate: (_) => newDeactivations++, + onDispose: (_) => newDisposals++, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + builds++; + return const SizedBox(); + }, + ), + ), + const _ThrowOnMountWidget(), + ]), + ); + FlutterError.onError = onError; + + expect(errors, hasLength(1)); + expect(errors.single.exception, isStateError); + expect(errors.single.stack.toString(), contains('_ThrowOnMountWidget.createRenderObject')); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(oldElement.debugIsDefunct, true); + expect(oldDisposals, 1); + expect(newDeactivations, 1); + expect(newDisposals, 1); + expect(notifier.listenerCount, 0); + + builds = 0; + notifier.notify(); + await tester.pump(); + expect(builds, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps a child moved out by a GlobalKey', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + var keyedDisposals = 0; + var newDisposals = 0; + final Widget keyed = KeyedSubtree( + key: key, + child: StatefulWidgetSpy(onDispose: (_) => keyedDisposals++), + ); + Widget buildTree({required bool moved}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + if (moved) keyed, + Builder( + builder: (_) => Stack( + children: [ + if (!moved) keyed, + if (moved) StatefulWidgetSpy(onDispose: (_) => newDisposals++), + if (moved) const _ThrowOnMountWidget(), + ], + ), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(moved: false)); + final keyedElement = key.currentContext! as Element; + + await tester.pumpWidget(buildTree(moved: true)); + + expect(tester.takeException(), isStateError); + expect(key.currentContext, same(keyedElement)); + expect(keyedElement.debugIsActive, true); + expect(keyedDisposals, 0); + expect(newDisposals, 1); + }); + + testWidgets('unmounts a child moved in by a GlobalKey once', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + var keyedDisposals = 0; + final Widget keyed = KeyedSubtree( + key: key, + child: StatefulWidgetSpy(onDispose: (_) => keyedDisposals++), + ); + Widget buildTree({required bool moved}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + if (!moved) keyed, + Builder( + builder: (_) => Stack( + children: [if (moved) keyed, if (moved) const _ThrowOnMountWidget()], + ), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(moved: false)); + final keyedElement = key.currentContext! as Element; + + await tester.pumpWidget(buildTree(moved: true)); + + expect(tester.takeException(), isStateError); + expect(keyedElement.debugIsDefunct, true); + expect(key.currentContext, isNull); + expect(keyedDisposals, 1); + + await tester.pumpWidget(buildTree(moved: false)); + expect(key.currentContext, isNotNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps the old children usable for a caller that retries the update', ( + WidgetTester tester, + ) async { + var disposals = 0; + Widget buildTree(List children) { + return Directionality( + textDirection: TextDirection.ltr, + child: _SurvivingStack(children: children), + ); + } + + final Widget kept = StatefulWidgetSpy( + key: const ValueKey('kept'), + onDispose: (_) => disposals++, + ); + await tester.pumpWidget(buildTree([kept])); + final Element keptElement = tester.element(find.byType(StatefulWidgetSpy)); + + await tester.pumpWidget(buildTree([kept, const _ThrowOnMountWidget()])); + await tester.pumpWidget(buildTree([kept, const SizedBox()])); + + expect(tester.takeException(), isNull); + expect(tester.element(find.byType(StatefulWidgetSpy)), same(keptElement)); + expect(keptElement.debugIsActive, true); + expect(disposals, 0); + }); + + testWidgets('stops a new child from building if deactivating it throws', ( + WidgetTester tester, + ) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var builds = 0; + late Element built; + await tester.pumpWidget(stackOf(const [])); + + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget( + stackOf([ + StatefulWidgetSpy( + onDeactivate: (_) => throw ArgumentError('deactivate'), + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + builds++; + built = context as Element; + return const SizedBox(); + }, + ), + ), + const _ThrowOnMountWidget(), + ]), + ); + FlutterError.onError = onError; + + expect(errors, hasLength(1)); + expect(errors.single.exception, isStateError); + expect(built.debugIsActive, false); + + builds = 0; + notifier.notify(); + await tester.pump(); + expect(builds, 0); + expect(tester.takeException(), isNull); + }); + }); +} + +// A Stack whose element survives a failing update and retries it with its old +// children on the next update, like an element that calls updateChildren from +// a performRebuild without catching. +class _SurvivingStack extends Stack { + const _SurvivingStack({super.children}); + + @override + MultiChildRenderObjectElement createElement() => _SurvivingStackElement(this); +} + +class _SurvivingStackElement extends MultiChildRenderObjectElement { + _SurvivingStackElement(_SurvivingStack super.widget); + + @override + void update(MultiChildRenderObjectWidget newWidget) { + try { + super.update(newWidget); + } on StateError { + // Leave the old children in place. + } + } } class _ThrowOnMountWidget extends LeafRenderObjectWidget { From d12f9c1e1a9afe6c5bf220cee8061a789937bfab Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 13:46:34 +0900 Subject: [PATCH 3/8] Keep finalizing the tree after an unmount throws _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 --- .../flutter/lib/src/widgets/framework.dart | 37 +++++- .../flutter/test/widgets/framework_test.dart | 117 ++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 8028c132b09b6..a51ee9afb1803 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -2100,7 +2100,11 @@ class _InactiveElements { bool _locked = false; final Set _elements = HashSet(); - static void _unmount(Element element) { + // Errors thrown while unmounting, after the first one that _unmountAll + // rethrows. They are reported once the build owner is unlocked. + final List<(Object, StackTrace)> _laterUnmountErrors = <(Object, StackTrace)>[]; + + static void _unmount(Element element, void Function(Object, StackTrace) onError) { assert(element._lifecycleState == _ElementLifecycle.inactive); assert(() { if (debugPrintGlobalKeyedWidgetLifecycle) { @@ -2112,9 +2116,16 @@ class _InactiveElements { }()); element.visitChildren((Element child) { assert(child._parent == element); - _unmount(child); + _unmount(child, onError); }); - element.unmount(); + try { + element.unmount(); + } catch (error, stack) { + // Keep unmounting the rest of the inactive elements. The element is not + // unmounted again: its dispose may already have run. + onError(error, stack); + return; + } assert(element._lifecycleState == _ElementLifecycle.defunct); } @@ -2122,12 +2133,26 @@ class _InactiveElements { _locked = true; final List elements = _elements.toList()..sort(Element._sort); _elements.clear(); + (Object, StackTrace)? firstError; + void onError(Object error, StackTrace stack) { + if (firstError == null) { + firstError = (error, stack); + } else { + _laterUnmountErrors.add((error, stack)); + } + } + try { - elements.reversed.forEach(_unmount); + for (final Element element in elements.reversed) { + _unmount(element, onError); + } } finally { assert(_elements.isEmpty); _locked = false; } + if (firstError case (final Object error, final StackTrace stack)) { + Error.throwWithStackTrace(error, stack); + } } static void _deactivateRecursively(Element element) { @@ -3437,6 +3462,10 @@ class BuildOwner { // Since the tree is in a broken state, adding the ErrorWidget would // cause more exceptions. _reportException(ErrorSummary('while finalizing the widget tree'), e, stack); + for (final (Object error, StackTrace errorStack) in _inactiveElements._laterUnmountErrors) { + _reportException(ErrorSummary('while finalizing the widget tree'), error, errorStack); + } + _inactiveElements._laterUnmountErrors.clear(); } finally { if (!kReleaseMode) { FlutterTimeline.finishSync(); diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 752838bc87c7d..797cc7d221c3a 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2541,6 +2541,123 @@ The findRenderObject() method was called for the following element: expect(tester.takeException(), isNull); }); }); + + group('a dispose that throws while finalizing the tree', () { + testWidgets('does not stop the other inactive elements from being unmounted', ( + WidgetTester tester, + ) async { + final GlobalKey key = GlobalKey(); + var throwOnDispose = true; + final disposals = {}; + void disposed(String name) => disposals[name] = (disposals[name] ?? 0) + 1; + Widget buildTree({required bool removed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + if (!removed) + StatefulWidgetSpy( + onDispose: (_) => disposed('parent'), + child: Stack( + children: [ + StatefulWidgetSpy( + onDispose: (_) { + disposed('throwing'); + if (throwOnDispose) { + throw StateError('dispose failed'); + } + }, + ), + StatefulWidgetSpy(onDispose: (_) => disposed('sibling')), + ], + ), + ), + if (!removed) + KeyedSubtree( + key: key, + child: StatefulWidgetSpy(onDispose: (_) => disposed('keyed')), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(removed: false)); + final List spies = tester.elementList(find.byType(StatefulWidgetSpy)).toList(); + + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget(buildTree(removed: true)); + FlutterError.onError = onError; + throwOnDispose = false; + + expect(errors, hasLength(1)); + expect((errors.single.exception as StateError).message, 'dispose failed'); + expect(errors.single.stack.toString(), contains('_StatefulWidgetSpyState.dispose')); + expect(disposals, {'throwing': 1, 'sibling': 1, 'parent': 1, 'keyed': 1}); + expect(spies.every((Element spy) => spy.debugIsDefunct), true); + expect(key.currentContext, isNull); + + await tester.pumpWidget(buildTree(removed: false)); + expect(tester.takeException(), isNull); + expect(key.currentContext, isNotNull); + }); + + testWidgets('reports every failed dispose once, after the tree is unlocked', ( + WidgetTester tester, + ) async { + var disposals = 0; + late StateSetter setProbeState; + Widget throwing(String message) => StatefulWidgetSpy( + onDispose: (_) { + disposals++; + throw StateError(message); + }, + ); + Widget buildTree({required bool removed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + setProbeState = setState; + return const SizedBox(); + }, + ), + if (!removed) throwing('first'), + if (!removed) throwing('second'), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(removed: false)); + + final disposeErrors = []; + final lockedErrors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + if (details.exception case final StateError error) { + disposeErrors.add(error.message); + try { + setProbeState(() {}); + } on FlutterError catch (error) { + if (error.toString().contains('when widget tree was locked')) { + lockedErrors.add(error); + } + } + } + }; + await tester.pumpWidget(buildTree(removed: true)); + FlutterError.onError = onError; + + expect(disposals, 2); + expect(disposeErrors, unorderedEquals(['first', 'second'])); + expect(lockedErrors, isEmpty); + }); + }); } // A Stack whose element survives a failing update and retries it with its old From 98ef627acb309d9c8903132c6bed8c8dfe8e53e6 Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 15:31:59 +0900 Subject: [PATCH 4/8] Unmount the healthy part of a subtree whose cleanup failed 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 --- .../flutter/lib/src/widgets/framework.dart | 111 ++++--- .../flutter/test/widgets/framework_test.dart | 271 ++++++++++++++++++ 2 files changed, 338 insertions(+), 44 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index a51ee9afb1803..95ee1ba0bb89d 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -2115,8 +2115,11 @@ class _InactiveElements { return true; }()); element.visitChildren((Element child) { - assert(child._parent == element); - _unmount(child, onError); + // A child no longer attached to element was already handed to this list + // or to a new parent (see ComponentElement.performRebuild). + if (child._parent == element) { + _unmount(child, onError); + } }); try { element.unmount(); @@ -2163,13 +2166,21 @@ class _InactiveElements { Element._deactivateFailedSubtreeRecursively(element); rethrow; } - element.visitChildren(_deactivateRecursively); + element.visitChildren(_deactivateAttachedChildRecursively); assert(() { element.debugDeactivated(); return true; }()); } + // A child that a failed update already detached and handed to this list has + // no parent, and is skipped. + static void _deactivateAttachedChildRecursively(Element child) { + if (child._parent != null) { + _deactivateRecursively(child); + } + } + void add(Element element) { assert(!_locked); assert(!_elements.contains(element)); @@ -4186,7 +4197,7 @@ abstract class Element extends DiagnosticableTree implements BuildContext { try { deactivateChild(newChild); } catch (_) { - _deactivateRemainingSubtreeSilently(newChild); + _salvageAfterFailedDeactivation(newChild); } } } @@ -4712,45 +4723,60 @@ abstract class Element extends DiagnosticableTree implements BuildContext { }()); } - static bool _isActiveSubtree(Element element) { - if (element._lifecycleState != _ElementLifecycle.active) { - return false; - } - var isActive = true; - element.visitChildren((Element child) { - isActive = isActive && child._parent == element && _isActiveSubtree(child); - }); - return isActive; - } - - // Like _deactivateFailedChildSilently, but only deactivates the elements - // that are still active, and skips descendants no longer attached to their - // parent: those were already handed to the inactive elements list or to a - // new parent. - void _deactivateRemainingSubtreeSilently(Element child) { + // Called when deactivating the detached child failed part-way. Hands every + // subtree that can still be unmounted to the inactive elements list, so it + // is unmounted (and its states disposed) at the end of the frame. Elements + // whose deactivate threw, and their ancestors, are marked failed instead and + // are not disposed. + void _salvageAfterFailedDeactivation(Element child) { + // If detaching the render object failed, the subtree's render objects are + // still in the render tree and must not be disposed. + bool canUnmountAny; try { - child._parent = null; - child.detachRenderObject(); - } catch (_) {} - void fail(Element element) { + canUnmountAny = child.renderObject?.parent == null; + } catch (_) { + canUnmountAny = false; + } + bool salvage(Element element) { if (element._lifecycleState == _ElementLifecycle.active) { try { element.deactivate(); } catch (_) { element._ensureDeactivated(); + element._lifecycleState = _ElementLifecycle.failed; } } - element._lifecycleState = _ElementLifecycle.failed; + final attached = <(Element, bool)>[]; try { element.visitChildren((Element descendant) { if (descendant._parent == element) { - fail(descendant); + attached.add((descendant, salvage(descendant))); } }); - } catch (_) {} + } catch (_) { + element._lifecycleState = _ElementLifecycle.failed; + } + if (canUnmountAny && + element._lifecycleState == _ElementLifecycle.inactive && + attached.every(((Element, bool) entry) => entry.$2)) { + return true; + } + element._lifecycleState = _ElementLifecycle.failed; + for (final (Element descendant, bool canUnmount) in attached) { + if (canUnmount) { + descendant._parent = null; + try { + descendant.detachRenderObject(); + } catch (_) {} + owner!._inactiveElements.add(descendant); + } + } + return false; } - fail(child); + if (salvage(child)) { + owner!._inactiveElements.add(child); + } } void _deactivateFailedChildSilently(Element child) { @@ -4781,7 +4807,11 @@ abstract class Element extends DiagnosticableTree implements BuildContext { } element._lifecycleState = _ElementLifecycle.failed; try { - element.visitChildren(_deactivateFailedSubtreeRecursively); + element.visitChildren((Element child) { + if (child._parent == element) { + _deactivateFailedSubtreeRecursively(child); + } + }); } catch (_) {} } @@ -5960,24 +5990,17 @@ abstract class ComponentElement extends Element { ], ), ); - // Make sure the old child subtree no longer builds: an intact subtree is - // deactivated and unmounted (disposing its states). If updateChild - // already deactivated it before failing, it is no longer our child. A - // subtree that was only partly updated (e.g. some of its children were - // already deactivated) can't go through the inactive elements list - // without being unmounted twice, so it is only marked failed and is not - // disposed. + // Make sure the old child subtree is deactivated and unmounted (disposing + // its states). If updateChild already deactivated it before failing, it + // is no longer our child. Children it detached from a partly updated + // subtree are skipped by the inactive elements list. final Element? oldChild = _child; _child = null; if (oldChild != null && oldChild._parent == this) { - if (Element._isActiveSubtree(oldChild)) { - try { - deactivateChild(oldChild); - } catch (_) { - _deactivateRemainingSubtreeSilently(oldChild); - } - } else { - _deactivateRemainingSubtreeSilently(oldChild); + try { + deactivateChild(oldChild); + } catch (_) { + _salvageAfterFailedDeactivation(oldChild); } } _child = updateChild(null, built, slot); diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 797cc7d221c3a..b12479980745e 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2658,6 +2658,260 @@ The findRenderObject() method was called for the following element: expect(lockedErrors, isEmpty); }); }); + + group('cleanup of a subtree whose update failed', () { + testWidgets('disposes the children still attached to a partly updated subtree', ( + WidgetTester tester, + ) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var removedDisposals = 0; + var keptDeactivations = 0; + var keptDisposals = 0; + Widget buildTree(Widget first) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => Stack( + children: [ + first, + StatefulWidgetSpy( + key: const ValueKey('kept'), + onDeactivate: (_) => keptDeactivations++, + onDispose: (_) => keptDisposals++, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildTree(StatefulWidgetSpy(onDispose: (_) => removedDisposals++))); + final Element stack = tester.element(find.byType(Stack)); + + await tester.pumpWidget(buildTree(const _ThrowOnMountWidget())); + + expect(tester.takeException(), isStateError); + expect(removedDisposals, 1); + expect(keptDeactivations, 1); + expect(keptDisposals, 1); + expect(notifier.listenerCount, 0); + expect(stack.debugIsDefunct, true); + }); + + testWidgets('deactivates a removed child once if its old parent then fails to deactivate', ( + WidgetTester tester, + ) async { + var removedDeactivations = 0; + var removedDisposals = 0; + Widget buildTree(Widget first) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => _ThrowOnDeactivateStack( + children: [ + first, + const SizedBox(key: ValueKey('kept')), + ], + ), + ), + ); + } + + await tester.pumpWidget( + buildTree( + StatefulWidgetSpy( + onDeactivate: (_) => removedDeactivations++, + onDispose: (_) => removedDisposals++, + ), + ), + ); + final Element removed = tester.element(find.byType(StatefulWidgetSpy)); + + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget(buildTree(const _ThrowOnMountWidget())); + FlutterError.onError = onError; + + expect(errors, hasLength(1)); + expect(errors.single.exception, isStateError); + expect(removedDeactivations, 1); + expect(removedDisposals, 1); + expect(removed.debugIsDefunct, true); + }); + + testWidgets('disposes the healthy elements if deactivating one of them throws', ( + WidgetTester tester, + ) async { + var throwOnDeactivate = false; + final deactivations = {}; + final disposals = {}; + void count(Map counts, String name) => counts[name] = (counts[name] ?? 0) + 1; + Widget spy(String name, {Widget child = const SizedBox()}) => StatefulWidgetSpy( + onDeactivate: (_) { + count(deactivations, name); + if (throwOnDeactivate && name == 'throwing') { + throw StateError('deactivate failed'); + } + }, + onDispose: (_) => count(disposals, name), + child: child, + ); + Widget buildTree({required bool throwOnUpdate}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: throwOnUpdate, + child: Stack( + children: [ + spy('before'), + spy('throwing', child: spy('throwing child')), + spy('after'), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(throwOnUpdate: false)); + final Element throwing = tester.element( + find.byWidgetPredicate( + (Widget widget) => widget is StatefulWidgetSpy && widget.child is StatefulWidgetSpy, + ), + ); + + throwOnDeactivate = true; + await tester.pumpWidget(buildTree(throwOnUpdate: true)); + throwOnDeactivate = false; + + expect(tester.takeException(), isStateError); + expect(deactivations['before'], 1); + expect(deactivations['after'], 1); + expect(disposals['before'], 1); + expect(disposals['after'], 1); + expect(disposals['throwing'], isNull); + expect(throwing.debugIsActive, false); + expect(throwing.debugIsDefunct, false); + }); + + testWidgets('does not dispose a subtree whose render object could not be detached', ( + WidgetTester tester, + ) async { + var disposals = 0; + var builds = 0; + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + Widget buildTree({required bool failing}) { + return Directionality( + textDirection: TextDirection.ltr, + child: _ThrowOnRemoveChildWidget( + throwOnRemove: failing, + child: Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: failing, + child: StatefulWidgetSpy( + onDispose: (_) => disposals++, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + builds++; + return const SizedBox(); + }, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(failing: false)); + final Element spy = tester.element(find.byType(StatefulWidgetSpy)); + + await tester.pumpWidget(buildTree(failing: true)); + + expect(tester.takeException(), isStateError); + expect(disposals, 0); + expect(spy.debugIsActive, false); + expect(spy.debugIsDefunct, false); + + builds = 0; + notifier.notify(); + await tester.pump(); + expect(builds, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('disposes a subtree that was detached before detaching threw', ( + WidgetTester tester, + ) async { + var disposals = 0; + Widget buildTree({required bool failing}) { + return Directionality( + textDirection: TextDirection.ltr, + child: _ThrowOnRemoveChildWidget( + throwOnRemove: failing, + throwAfterRemoving: true, + child: Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: failing, + child: StatefulWidgetSpy(onDispose: (_) => disposals++), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(failing: false)); + final Element spy = tester.element(find.byType(StatefulWidgetSpy)); + + await tester.pumpWidget(buildTree(failing: true)); + + expect(tester.takeException(), isStateError); + expect(disposals, 1); + expect(spy.debugIsDefunct, true); + }); + }); +} + +class _ThrowOnRemoveChildWidget extends SingleChildRenderObjectWidget { + const _ThrowOnRemoveChildWidget({ + required this.throwOnRemove, + this.throwAfterRemoving = false, + super.child, + }); + + final bool throwOnRemove; + final bool throwAfterRemoving; + + @override + SingleChildRenderObjectElement createElement() => _ThrowOnRemoveChildElement(this); + + @override + RenderObject createRenderObject(BuildContext context) => RenderProxyBox(); +} + +class _ThrowOnRemoveChildElement extends SingleChildRenderObjectElement { + _ThrowOnRemoveChildElement(_ThrowOnRemoveChildWidget super.widget); + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + final widget = this.widget as _ThrowOnRemoveChildWidget; + if (widget.throwOnRemove && !widget.throwAfterRemoving) { + throw StateError('remove failed'); + } + super.removeRenderObjectChild(child, slot); + if (widget.throwOnRemove) { + throw StateError('remove failed'); + } + } } // A Stack whose element survives a failing update and retries it with its old @@ -2683,6 +2937,23 @@ class _SurvivingStackElement extends MultiChildRenderObjectElement { } } +class _ThrowOnDeactivateStack extends Stack { + const _ThrowOnDeactivateStack({super.children}); + + @override + MultiChildRenderObjectElement createElement() => _ThrowOnDeactivateStackElement(this); +} + +class _ThrowOnDeactivateStackElement extends MultiChildRenderObjectElement { + _ThrowOnDeactivateStackElement(_ThrowOnDeactivateStack super.widget); + + @override + void deactivate() { + super.deactivate(); + throw StateError('deactivate failed'); + } +} + class _ThrowOnMountWidget extends LeafRenderObjectWidget { const _ThrowOnMountWidget(); From 0aacdcbd92cd1fb484237f2adc977e3fb5587f8e Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 15:33:03 +0900 Subject: [PATCH 5/8] Release the GlobalKeys of elements left failed by the cleanup 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 --- .../flutter/lib/src/widgets/framework.dart | 9 +++- .../flutter/test/widgets/framework_test.dart | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 95ee1ba0bb89d..d16cfa8ce16cb 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -4726,8 +4726,8 @@ abstract class Element extends DiagnosticableTree implements BuildContext { // Called when deactivating the detached child failed part-way. Hands every // subtree that can still be unmounted to the inactive elements list, so it // is unmounted (and its states disposed) at the end of the frame. Elements - // whose deactivate threw, and their ancestors, are marked failed instead and - // are not disposed. + // whose deactivate threw, and their ancestors, are marked failed instead: + // they are not disposed, but their GlobalKeys are released. void _salvageAfterFailedDeactivation(Element child) { // If detaching the render object failed, the subtree's render objects are // still in the render tree and must not be disposed. @@ -4762,6 +4762,11 @@ abstract class Element extends DiagnosticableTree implements BuildContext { return true; } element._lifecycleState = _ElementLifecycle.failed; + // A failed element is never unmounted, so release its GlobalKey here: + // otherwise a later widget with that key would try to retake it. + if (element.widget.key case final GlobalKey key) { + owner!._unregisterGlobalKey(key, element); + } for (final (Element descendant, bool canUnmount) in attached) { if (canUnmount) { descendant._parent = null; diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index b12479980745e..78fb4978dc96d 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2878,6 +2878,49 @@ The findRenderObject() method was called for the following element: expect(disposals, 1); expect(spy.debugIsDefunct, true); }); + + testWidgets('releases the GlobalKeys of the elements left failed', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + var throwOnDeactivate = false; + var builds = 0; + Widget buildTree({required bool failing}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (_) => _ThrowOnUpdateWidget( + throwOnUpdate: failing, + child: KeyedSubtree( + key: key, + child: StatefulWidgetSpy( + onBuild: (_) => builds++, + onDeactivate: (_) { + if (throwOnDeactivate) { + throw StateError('deactivate failed'); + } + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTree(failing: false)); + final failed = key.currentContext! as Element; + throwOnDeactivate = true; + await tester.pumpWidget(buildTree(failing: true)); + throwOnDeactivate = false; + expect(tester.takeException(), isStateError); + expect(key.currentContext, isNull); + + builds = 0; + await tester.pumpWidget(buildTree(failing: false)); + + expect(tester.takeException(), isNull); + expect(key.currentContext, isNot(same(failed))); + expect((key.currentContext! as Element).debugIsActive, true); + expect(builds, 1); + }); }); } From 134ac2492c351d3faf410be3c35f0c9e21cf52f2 Mon Sep 17 00:00:00 2001 From: Cla Date: Tue, 27 Oct 2026 15:36:49 +0900 Subject: [PATCH 6/8] Unmount the healthy part of a removed subtree whose deactivate throws 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 --- .../flutter/lib/src/widgets/framework.dart | 9 ++- .../flutter/test/widgets/framework_test.dart | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index d16cfa8ce16cb..1eb8902fafb77 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -2188,7 +2188,14 @@ class _InactiveElements { switch (element._lifecycleState) { case _ElementLifecycle.active: - _deactivateRecursively(element); + try { + _deactivateRecursively(element); + } catch (_) { + // The element was already detached, so nothing else will unmount + // the part of its subtree that deactivated fine. + element._salvageAfterFailedDeactivation(element); + rethrow; + } // This element is only added to _elements if the whole subtree is // successfully deactivated. _elements.add(element); diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 78fb4978dc96d..e1396fa628b76 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2922,6 +2922,65 @@ The findRenderObject() method was called for the following element: expect(builds, 1); }); }); + + testWidgets('removing a subtree whose deactivate throws still unmounts its healthy part', ( + WidgetTester tester, + ) async { + final notifier = _ListenerCountingNotifier(); + addTearDown(notifier.dispose); + var throwOnDeactivate = false; + final builds = {}; + final disposals = {}; + void count(Map counts, String name) => counts[name] = (counts[name] ?? 0) + 1; + Widget spy(String name) => StatefulWidgetSpy( + onDeactivate: (_) { + if (throwOnDeactivate && name == 'throwing') { + throw StateError('deactivate failed'); + } + }, + onDispose: (_) => count(disposals, name), + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + count(builds, name); + return const SizedBox(); + }, + ), + ); + Widget buildTree({required bool removed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + if (!removed) Stack(children: [spy('before'), spy('throwing'), spy('after')]), + spy('stays'), + ], + ), + ); + } + + await tester.pumpWidget(buildTree(removed: false)); + throwOnDeactivate = true; + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget(buildTree(removed: true)); + FlutterError.onError = onError; + throwOnDeactivate = false; + + expect( + errors.map((FlutterErrorDetails details) => details.exception), + contains(isA()), + ); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(disposals, {'before': 1, 'after': 1, 'stays': 1}); + + builds.clear(); + notifier.notify(); + await tester.pump(); + expect(builds, isEmpty); + expect(tester.takeException(), isNull); + }); } class _ThrowOnRemoveChildWidget extends SingleChildRenderObjectWidget { From 5dec1baad414273cb57a8fe96c07724cbc68ed84 Mon Sep 17 00:00:00 2001 From: Cla Date: Wed, 28 Oct 2026 11:57:11 +0900 Subject: [PATCH 7/8] Fix duplicate salvage after debug deactivation failures --- .../flutter/lib/src/widgets/framework.dart | 8 +- .../flutter/test/widgets/framework_test.dart | 86 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 1eb8902fafb77..741aa98706f2f 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -4204,7 +4204,9 @@ abstract class Element extends DiagnosticableTree implements BuildContext { try { deactivateChild(newChild); } catch (_) { - _salvageAfterFailedDeactivation(newChild); + if (newChild._lifecycleState == _ElementLifecycle.active) { + _salvageAfterFailedDeactivation(newChild); + } } } } @@ -6012,7 +6014,9 @@ abstract class ComponentElement extends Element { try { deactivateChild(oldChild); } catch (_) { - _salvageAfterFailedDeactivation(oldChild); + if (oldChild._lifecycleState == _ElementLifecycle.active) { + _salvageAfterFailedDeactivation(oldChild); + } } } _child = updateChild(null, built, slot); diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index e1396fa628b76..c588aafdd6581 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -2923,6 +2923,70 @@ The findRenderObject() method was called for the following element: }); }); + for (final newChild in [false, true]) { + testWidgets('salvages a debug deactivation failure once (new child: $newChild)', ( + WidgetTester tester, + ) async { + final GlobalKey rootKey = GlobalKey(); + var rootDisposals = 0; + var childDeactivations = 0; + var childDisposals = 0; + Widget tree(bool fail) { + final Widget child = _ThrowOnDebugDeactivated( + child: StatefulWidgetSpy( + onDeactivate: (_) => childDeactivations++, + onDispose: (_) => childDisposals++, + ), + ); + return Directionality( + textDirection: TextDirection.ltr, + child: StatefulWidgetSpy( + key: rootKey, + onDispose: (_) => rootDisposals++, + child: newChild + ? Stack( + children: [ + const SizedBox(key: ValueKey('first')), + if (fail) child, + if (fail) const _ThrowOnMountWidget(), + const SizedBox(key: ValueKey('last')), + ], + ) + : _ThrowOnUpdateWidget(throwOnUpdate: fail, child: child), + ), + ); + } + + await tester.pumpWidget(tree(false)); + final root = rootKey.currentContext! as Element; + final errors = []; + final FlutterExceptionHandler? onError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await tester.pumpWidget(tree(true)); + } finally { + FlutterError.onError = onError; + } + + expect(errors, hasLength(1)); + expect(errors.single.exception, isA()); + expect(errors.single.exception.toString(), contains('kaboom')); + expect(rootKey.currentContext, same(root)); + expect(root.debugIsActive, true); + expect(rootDisposals, 0); + expect( + find.descendant(of: find.byKey(rootKey), matching: find.byType(ErrorWidget)), + findsOneWidget, + ); + expect(childDeactivations, 1); + expect(childDisposals, 1); + await tester.pumpWidget(const SizedBox()); + expect(rootDisposals, 1); + expect(childDisposals, 1); + expect(tester.takeException(), isNull); + }); + } + testWidgets('removing a subtree whose deactivate throws still unmounts its healthy part', ( WidgetTester tester, ) async { @@ -2983,6 +3047,28 @@ The findRenderObject() method was called for the following element: }); } +class _ThrowOnDebugDeactivated extends StatelessWidget { + const _ThrowOnDebugDeactivated({required this.child}); + + final Widget child; + + @override + StatelessElement createElement() => _ThrowOnDebugDeactivatedElement(this); + + @override + Widget build(BuildContext context) => child; +} + +class _ThrowOnDebugDeactivatedElement extends StatelessElement { + _ThrowOnDebugDeactivatedElement(_ThrowOnDebugDeactivated super.widget); + + @override + void debugDeactivated() { + super.debugDeactivated(); + throw StateError('debug deactivation failed'); + } +} + class _ThrowOnRemoveChildWidget extends SingleChildRenderObjectWidget { const _ThrowOnRemoveChildWidget({ required this.throwOnRemove, From 5916bcb60ea1fd975fc67f76f0629524f7b4f319 Mon Sep 17 00:00:00 2001 From: Cla Date: Wed, 28 Oct 2026 11:57:11 +0900 Subject: [PATCH 8/8] Clarify best-effort deactivation salvage limits --- packages/flutter/lib/src/widgets/framework.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 741aa98706f2f..ca00329ac1e62 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -2173,8 +2173,8 @@ class _InactiveElements { }()); } - // A child that a failed update already detached and handed to this list has - // no parent, and is skipped. + // Failed updates can leave stale child references. Recurse only through + // children that are still attached. static void _deactivateAttachedChildRecursively(Element child) { if (child._parent != null) { _deactivateRecursively(child); @@ -4736,7 +4736,10 @@ abstract class Element extends DiagnosticableTree implements BuildContext { // subtree that can still be unmounted to the inactive elements list, so it // is unmounted (and its states disposed) at the end of the frame. Elements // whose deactivate threw, and their ancestors, are marked failed instead: - // they are not disposed, but their GlobalKeys are released. + // they are not disposed, but their GlobalKeys are released. Descendants + // already marked failed by the first deactivation error stay failed. Later + // deactivation errors are suppressed here, and their children are still + // visited for possible unmounting. void _salvageAfterFailedDeactivation(Element child) { // If detaching the render object failed, the subtree's render objects are // still in the render tree and must not be disposed.