Skip to content

Commit ea5cfe0

Browse files
authored
Properly cleans up routes (#126453)
fixes flutter/flutter#126100
1 parent af83c76 commit ea5cfe0

File tree

6 files changed

+143
-66
lines changed

6 files changed

+143
-66
lines changed

packages/flutter/lib/src/widgets/heroes.dart

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -849,40 +849,47 @@ class HeroController extends NavigatorObserver {
849849
HeroFlightDirection flightType,
850850
bool isUserGestureTransition,
851851
) {
852-
if (toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
853-
final PageRoute<dynamic> from = fromRoute;
854-
final PageRoute<dynamic> to = toRoute;
852+
if (toRoute == fromRoute ||
853+
toRoute is! PageRoute<dynamic> ||
854+
fromRoute is! PageRoute<dynamic>) {
855+
return;
856+
}
855857

856-
// A user gesture may have already completed the pop, or we might be the initial route
857-
switch (flightType) {
858-
case HeroFlightDirection.pop:
859-
if (from.animation!.value == 0.0) {
860-
return;
861-
}
862-
case HeroFlightDirection.push:
863-
if (to.animation!.value == 1.0) {
864-
return;
865-
}
866-
}
858+
final PageRoute<dynamic> from = fromRoute;
859+
final PageRoute<dynamic> to = toRoute;
867860

868-
// For pop transitions driven by a user gesture: if the "to" page has
869-
// maintainState = true, then the hero's final dimensions can be measured
870-
// immediately because their page's layout is still valid.
871-
if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) {
872-
_startHeroTransition(from, to, flightType, isUserGestureTransition);
873-
} else {
874-
// Otherwise, delay measuring until the end of the next frame to allow
875-
// the 'to' route to build and layout.
861+
// A user gesture may have already completed the pop, or we might be the initial route
862+
switch (flightType) {
863+
case HeroFlightDirection.pop:
864+
if (from.animation!.value == 0.0) {
865+
return;
866+
}
867+
case HeroFlightDirection.push:
868+
if (to.animation!.value == 1.0) {
869+
return;
870+
}
871+
}
876872

877-
// Putting a route offstage changes its animation value to 1.0. Once this
878-
// frame completes, we'll know where the heroes in the `to` route are
879-
// going to end up, and the `to` route will go back onstage.
880-
to.offstage = to.animation!.value == 0.0;
873+
// For pop transitions driven by a user gesture: if the "to" page has
874+
// maintainState = true, then the hero's final dimensions can be measured
875+
// immediately because their page's layout is still valid.
876+
if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) {
877+
_startHeroTransition(from, to, flightType, isUserGestureTransition);
878+
} else {
879+
// Otherwise, delay measuring until the end of the next frame to allow
880+
// the 'to' route to build and layout.
881881

882-
WidgetsBinding.instance.addPostFrameCallback((Duration value) {
883-
_startHeroTransition(from, to, flightType, isUserGestureTransition);
884-
});
885-
}
882+
// Putting a route offstage changes its animation value to 1.0. Once this
883+
// frame completes, we'll know where the heroes in the `to` route are
884+
// going to end up, and the `to` route will go back onstage.
885+
to.offstage = to.animation!.value == 0.0;
886+
887+
WidgetsBinding.instance.addPostFrameCallback((Duration value) {
888+
if (from.navigator == null || to.navigator == null) {
889+
return;
890+
}
891+
_startHeroTransition(from, to, flightType, isUserGestureTransition);
892+
});
886893
}
887894
}
888895

packages/flutter/lib/src/widgets/navigator.dart

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2786,6 +2786,7 @@ class Navigator extends StatefulWidget {
27862786
// \ |
27872787
// dispose*
27882788
// |
2789+
// disposing
27892790
// |
27902791
// disposed
27912792
// |
@@ -2821,6 +2822,9 @@ enum _RouteLifecycle {
28212822
removing, // we are waiting for subsequent routes to be done animating, then will switch to dispose
28222823
// routes that are completely removed from the navigator and overlay.
28232824
dispose, // we will dispose the route momentarily
2825+
disposing, // The entry is waiting for its widget subtree to be disposed
2826+
// first. It is stored in _entryWaitingForSubTreeDisposal while
2827+
// awaiting that.
28242828
disposed, // we have disposed the route
28252829
}
28262830

@@ -3047,9 +3051,26 @@ class _RouteEntry extends RouteTransitionRecord {
30473051
currentState = _RouteLifecycle.dispose;
30483052
}
30493053

3050-
void dispose() {
3054+
/// Disposes this route entry and its [route] immediately.
3055+
///
3056+
/// This method does not wait for the widget subtree of the [route] to unmount
3057+
/// before disposing.
3058+
void forcedDispose() {
30513059
assert(currentState.index < _RouteLifecycle.disposed.index);
30523060
currentState = _RouteLifecycle.disposed;
3061+
route.dispose();
3062+
}
3063+
3064+
/// Disposes this route entry and its [route].
3065+
///
3066+
/// This method waits for the widget subtree of the [route] to unmount before
3067+
/// disposing. If subtree is already unmounted, this method calls
3068+
/// [forcedDispose] immediately.
3069+
///
3070+
/// Use [forcedDispose] if the [route] need to be disposed immediately.
3071+
void dispose() {
3072+
assert(currentState.index < _RouteLifecycle.disposing.index);
3073+
currentState = _RouteLifecycle.disposing;
30533074

30543075
// If the overlay entries are still mounted, widgets in the route's subtree
30553076
// may still reference resources from the route and we delay disposal of
@@ -3060,24 +3081,43 @@ class _RouteEntry extends RouteTransitionRecord {
30603081
final Iterable<OverlayEntry> mountedEntries = route.overlayEntries.where((OverlayEntry e) => e.mounted);
30613082

30623083
if (mountedEntries.isEmpty) {
3063-
route.dispose();
3064-
} else {
3065-
int mounted = mountedEntries.length;
3066-
assert(mounted > 0);
3067-
for (final OverlayEntry entry in mountedEntries) {
3068-
late VoidCallback listener;
3069-
listener = () {
3070-
assert(mounted > 0);
3071-
assert(!entry.mounted);
3072-
mounted--;
3073-
entry.removeListener(listener);
3074-
if (mounted == 0) {
3075-
assert(route.overlayEntries.every((OverlayEntry e) => !e.mounted));
3076-
route.dispose();
3077-
}
3078-
};
3079-
entry.addListener(listener);
3080-
}
3084+
forcedDispose();
3085+
return;
3086+
}
3087+
3088+
int mounted = mountedEntries.length;
3089+
assert(mounted > 0);
3090+
final NavigatorState navigator = route._navigator!;
3091+
navigator._entryWaitingForSubTreeDisposal.add(this);
3092+
for (final OverlayEntry entry in mountedEntries) {
3093+
late VoidCallback listener;
3094+
listener = () {
3095+
assert(mounted > 0);
3096+
assert(!entry.mounted);
3097+
mounted--;
3098+
entry.removeListener(listener);
3099+
if (mounted == 0) {
3100+
assert(route.overlayEntries.every((OverlayEntry e) => !e.mounted));
3101+
// This is a listener callback of one of the overlayEntries in this
3102+
// route. Disposing the route also disposes its overlayEntries and
3103+
// violates the rule that a change notifier can't be disposed during
3104+
// its notifying callback.
3105+
//
3106+
// Use a microtask to ensure the overlayEntries have finished
3107+
// notifying their listeners before disposing.
3108+
return scheduleMicrotask(() {
3109+
if (!navigator._entryWaitingForSubTreeDisposal.remove(this)) {
3110+
// This route must have been destroyed as a result of navigator
3111+
// force dispose.
3112+
assert(route._navigator == null && !navigator.mounted);
3113+
return;
3114+
}
3115+
assert(currentState == _RouteLifecycle.disposing);
3116+
forcedDispose();
3117+
});
3118+
}
3119+
};
3120+
entry.addListener(listener);
30813121
}
30823122
}
30833123

@@ -3257,6 +3297,15 @@ class _NavigatorReplaceObservation extends _NavigatorObservation {
32573297
class NavigatorState extends State<Navigator> with TickerProviderStateMixin, RestorationMixin {
32583298
late GlobalKey<OverlayState> _overlayKey;
32593299
List<_RouteEntry> _history = <_RouteEntry>[];
3300+
/// A set for entries that are waiting to dispose until their subtrees are
3301+
/// disposed.
3302+
///
3303+
/// These entries are not considered to be in the _history and will usually
3304+
/// remove themselves from this set once they can dispose.
3305+
///
3306+
/// The navigator keep track of these entries so that, in case the navigator
3307+
/// itself is disposed, it can dispose these entries immediately.
3308+
final Set<_RouteEntry> _entryWaitingForSubTreeDisposal = <_RouteEntry>{};
32603309
final _HistoryProperty _serializableHistory = _HistoryProperty();
32613310
final Queue<_NavigatorObservation> _observedRouteAdditions = Queue<_NavigatorObservation>();
32623311
final Queue<_NavigatorObservation> _observedRouteDeletions = Queue<_NavigatorObservation>();
@@ -3338,9 +3387,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
33383387
registerForRestoration(_serializableHistory, 'history');
33393388

33403389
// Delete everything in the old history and clear the overlay.
3341-
while (_history.isNotEmpty) {
3342-
_history.removeLast().dispose();
3343-
}
3390+
_forcedDisposeAllRouteEntries();
33443391
assert(_history.isEmpty);
33453392
_overlayKey = GlobalKey<OverlayState>();
33463393

@@ -3423,6 +3470,28 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
34233470
}
34243471
}
34253472

3473+
/// Dispose all lingering router entries immediately.
3474+
void _forcedDisposeAllRouteEntries() {
3475+
_entryWaitingForSubTreeDisposal.removeWhere((_RouteEntry entry) {
3476+
entry.forcedDispose();
3477+
return true;
3478+
});
3479+
while (_history.isNotEmpty) {
3480+
_disposeRouteEntry(_history.removeLast(), graceful: false);
3481+
}
3482+
}
3483+
3484+
static void _disposeRouteEntry(_RouteEntry entry, {required bool graceful}) {
3485+
for (final OverlayEntry overlayEntry in entry.route.overlayEntries) {
3486+
overlayEntry.remove();
3487+
}
3488+
if (graceful) {
3489+
entry.dispose();
3490+
} else {
3491+
entry.forcedDispose();
3492+
}
3493+
}
3494+
34263495
void _updateHeroController(HeroController? newHeroController) {
34273496
if (_heroControllerFromScope != newHeroController) {
34283497
if (newHeroController != null) {
@@ -3595,9 +3664,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
35953664
}());
35963665
_updateHeroController(null);
35973666
focusNode.dispose();
3598-
for (final _RouteEntry entry in _history) {
3599-
entry.dispose();
3600-
}
3667+
_forcedDisposeAllRouteEntries();
36013668
_rawNextPagelessRestorationScopeId.dispose();
36023669
_serializableHistory.dispose();
36033670
userGestureInProgressNotifier.dispose();
@@ -4022,6 +4089,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
40224089
// Delay disposal until didChangeNext/didChangePrevious have been sent.
40234090
toBeDisposed.add(_history.removeAt(index));
40244091
entry = next;
4092+
case _RouteLifecycle.disposing:
40254093
case _RouteLifecycle.disposed:
40264094
case _RouteLifecycle.staging:
40274095
assert(false);
@@ -4051,10 +4119,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
40514119
// Lastly, removes the overlay entries of all marked entries and disposes
40524120
// them.
40534121
for (final _RouteEntry entry in toBeDisposed) {
4054-
for (final OverlayEntry overlayEntry in entry.route.overlayEntries) {
4055-
overlayEntry.remove();
4056-
}
4057-
entry.dispose();
4122+
_disposeRouteEntry(entry, graceful: true);
40584123
}
40594124
if (rearrangeOverlay) {
40604125
overlay?.rearrange(_allRouteOverlayEntries);

packages/flutter/lib/src/widgets/routes.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ abstract class OverlayRoute<T> extends Route<T> {
8484

8585
@override
8686
void dispose() {
87+
for (final OverlayEntry entry in _overlayEntries) {
88+
entry.dispose();
89+
}
8790
_overlayEntries.clear();
8891
super.dispose();
8992
}
@@ -704,7 +707,9 @@ mixin LocalHistoryRoute<T> on Route<T> {
704707
// elements during finalizeTree. The state is locked at this moment, and
705708
// we can only notify state has changed in the next frame.
706709
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
707-
changedInternalState();
710+
if (isActive) {
711+
changedInternalState();
712+
}
708713
});
709714
} else {
710715
changedInternalState();

packages/flutter/test/material/about_test.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ void main() {
427427
final FakeLicenseEntry licenseEntry = FakeLicenseEntry();
428428
licenseCompleter.complete(licenseEntry);
429429
expect(licenseEntry.packagesCalled, false);
430-
}, leakTrackingConfig: const LeakTrackingTestConfig(notDisposedAllowList: <String, int?>{'ValueNotifier<_OverlayEntryWidgetState?>': null})); // TODO(goderbauer): Fix leak, https://github.com/flutter/flutter/issues/126100.
430+
});
431431

432432
testWidgetsWithLeakTracking('LicensePage returns late if unmounted', (WidgetTester tester) async {
433433
final Completer<LicenseEntry> licenseCompleter = Completer<LicenseEntry>();
@@ -452,7 +452,7 @@ void main() {
452452

453453
await tester.pumpAndSettle();
454454
expect(licenseEntry.packagesCalled, true);
455-
}, leakTrackingConfig: const LeakTrackingTestConfig(notDisposedAllowList: <String, int?>{'ValueNotifier<_OverlayEntryWidgetState?>': null})); // TODO(goderbauer): Fix leak, https://github.com/flutter/flutter/issues/126100.
455+
});
456456

457457
testWidgetsWithLeakTracking('LicensePage logic defaults to executable name for app name', (WidgetTester tester) async {
458458
await tester.pumpWidget(
@@ -1128,7 +1128,7 @@ void main() {
11281128

11291129
// Configure to show the default layout.
11301130
await tester.binding.setSurfaceSize(defaultSize);
1131-
}, leakTrackingConfig: const LeakTrackingTestConfig(notDisposedAllowList: <String, int?>{'ValueNotifier<_OverlayEntryWidgetState?>': null})); // TODO(goderbauer): Fix leak, https://github.com/flutter/flutter/issues/126100.
1131+
});
11321132

11331133
testWidgetsWithLeakTracking('LicensePage master view layout position - rtl', (WidgetTester tester) async {
11341134
const TextDirection textDirection = TextDirection.rtl;
@@ -1191,7 +1191,7 @@ void main() {
11911191

11921192
// Configure to show the default layout.
11931193
await tester.binding.setSurfaceSize(defaultSize);
1194-
}, leakTrackingConfig: const LeakTrackingTestConfig(notDisposedAllowList: <String, int?>{'ValueNotifier<_OverlayEntryWidgetState?>': null})); // TODO(goderbauer): Fix leak, https://github.com/flutter/flutter/issues/126100.
1194+
});
11951195

11961196
testWidgetsWithLeakTracking('License page title in lateral UI does not use AppBarTheme.foregroundColor', (WidgetTester tester) async {
11971197
// This is a regression test for https://github.com/flutter/flutter/issues/108991

packages/flutter/test/widgets/navigator_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4171,7 +4171,7 @@ class RouteAnnouncementSpy extends Route<void> {
41714171
final AnnouncementCallBack? onDidPopNext;
41724172

41734173
@override
4174-
List<OverlayEntry> get overlayEntries => <OverlayEntry>[
4174+
final List<OverlayEntry> overlayEntries = <OverlayEntry>[
41754175
OverlayEntry(
41764176
builder: (BuildContext context) => const Placeholder(),
41774177
),

packages/flutter/test/widgets/routes_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ void main() {
401401
],
402402
);
403403
await tester.pumpWidget(Container());
404-
expect(results, equals(<String>['A: dispose', 'b: dispose']));
404+
expect(results, equals(<String>['b: dispose', 'A: dispose']));
405405
expect(routes.isEmpty, isTrue);
406406
results.clear();
407407
});

0 commit comments

Comments
 (0)