From da5f8051d37b941783e21922b42370393519d02a Mon Sep 17 00:00:00 2001 From: Daiki Kajiwara Date: Fri, 13 Mar 2026 16:51:34 +0900 Subject: [PATCH 1/2] fix(go_router): prevent pop() from restoring stale config when route has onExit When a route has onExit, _handlePopPageWithRouteMatch defers the pop to a microtask. Calling restore() immediately with the pre-pop currentConfiguration can trigger async redirects that overwrite the eventual pop result. Detect synchronous completion by comparing currentConfiguration identity before and after the pop call, avoiding any mutable flag on the delegate. --- packages/go_router/lib/src/router.dart | 7 +- packages/go_router/test/on_exit_test.dart | 85 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 7e885742ec52..de8bdf1b8887 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -583,8 +583,13 @@ class GoRouter implements RouterConfig { log('popping ${routerDelegate.currentConfiguration.uri}'); return true; }()); + final RouteMatchList configBeforePop = routerDelegate.currentConfiguration; routerDelegate.pop(result); - restore(routerDelegate.currentConfiguration); + // Only restore when the pop completed synchronously (no onExit). + // If deferred, currentConfiguration is still the same instance. + if (!identical(routerDelegate.currentConfiguration, configBeforePop)) { + restore(routerDelegate.currentConfiguration); + } } /// Refresh the route. diff --git a/packages/go_router/test/on_exit_test.dart b/packages/go_router/test/on_exit_test.dart index 2b1d0873cb95..f48827845ddc 100644 --- a/packages/go_router/test/on_exit_test.dart +++ b/packages/go_router/test/on_exit_test.dart @@ -493,4 +493,89 @@ void main() { expect(onExitState2.fullPath, '/route-2/:id2'); }, ); + + // Regression test: pop() with onExit + async redirect must not restore + // stale configuration. + testWidgets( + 'pop does not call restore with stale config when route has onExit', + (WidgetTester tester) async { + final homeKey = UniqueKey(); + final detailKey = UniqueKey(); + + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, __) => DummyScreen(key: homeKey), + routes: [ + GoRoute( + path: 'detail', + onExit: (_, __) => true, + builder: (_, __) => DummyScreen(key: detailKey), + ), + ], + ), + ], + tester, + initialLocation: '/detail', + redirect: (_, GoRouterState state) async { + // Async redirect — completes in a later microtask. + await Future.delayed(Duration.zero); + return null; + }, + ); + + await tester.pumpAndSettle(); + expect(find.byKey(detailKey), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + + // The detail route should be gone after pop. + expect( + find.byKey(detailKey), + findsNothing, + reason: 'Route with onExit should be properly popped ' + 'even when async redirect is present', + ); + expect(find.byKey(homeKey), findsOneWidget); + }, + ); + + // Verify that pop is correctly cancelled when onExit returns false. + testWidgets( + 'pop is cancelled when onExit returns false', + (WidgetTester tester) async { + final homeKey = UniqueKey(); + final detailKey = UniqueKey(); + + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, __) => DummyScreen(key: homeKey), + routes: [ + GoRoute( + path: 'detail', + onExit: (_, __) => false, // Always prevent leaving. + builder: (_, __) => DummyScreen(key: detailKey), + ), + ], + ), + ], + tester, + initialLocation: '/detail', + ); + + await tester.pumpAndSettle(); + expect(find.byKey(detailKey), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + + // Should still be on the detail page. + expect(find.byKey(detailKey), findsOneWidget); + expect(find.byKey(homeKey), findsNothing); + }, + ); } From 030282c5ae05fd7f2bd2ce6e3054abe2aaa7ac3d Mon Sep 17 00:00:00 2001 From: Daiki Kajiwara Date: Fri, 13 Mar 2026 17:04:30 +0900 Subject: [PATCH 2/2] [go_router] Bump version to 17.1.1, format test, add CHANGELOG --- packages/go_router/CHANGELOG.md | 4 ++ packages/go_router/pubspec.yaml | 2 +- packages/go_router/test/on_exit_test.dart | 64 +++++++++++------------ 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index f1d95f90c9fc..9fa25e3824c9 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 17.1.1 + +- Fixes `pop()` restoring stale configuration when route has `onExit`, which could cause the popped route to reappear with async redirects. + ## 17.1.0 - Adds `TypedQueryParameter` annotation to override parameter names in `TypedGoRoute` constructors. diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 55ad56b1058c..91402a841cb7 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 17.1.0 +version: 17.1.1 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/on_exit_test.dart b/packages/go_router/test/on_exit_test.dart index f48827845ddc..56f5c6119698 100644 --- a/packages/go_router/test/on_exit_test.dart +++ b/packages/go_router/test/on_exit_test.dart @@ -535,7 +535,8 @@ void main() { expect( find.byKey(detailKey), findsNothing, - reason: 'Route with onExit should be properly popped ' + reason: + 'Route with onExit should be properly popped ' 'even when async redirect is present', ); expect(find.byKey(homeKey), findsOneWidget); @@ -543,39 +544,38 @@ void main() { ); // Verify that pop is correctly cancelled when onExit returns false. - testWidgets( - 'pop is cancelled when onExit returns false', - (WidgetTester tester) async { - final homeKey = UniqueKey(); - final detailKey = UniqueKey(); + testWidgets('pop is cancelled when onExit returns false', ( + WidgetTester tester, + ) async { + final homeKey = UniqueKey(); + final detailKey = UniqueKey(); - final GoRouter router = await createRouter( - [ - GoRoute( - path: '/', - builder: (_, __) => DummyScreen(key: homeKey), - routes: [ - GoRoute( - path: 'detail', - onExit: (_, __) => false, // Always prevent leaving. - builder: (_, __) => DummyScreen(key: detailKey), - ), - ], - ), - ], - tester, - initialLocation: '/detail', - ); + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, __) => DummyScreen(key: homeKey), + routes: [ + GoRoute( + path: 'detail', + onExit: (_, __) => false, // Always prevent leaving. + builder: (_, __) => DummyScreen(key: detailKey), + ), + ], + ), + ], + tester, + initialLocation: '/detail', + ); - await tester.pumpAndSettle(); - expect(find.byKey(detailKey), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byKey(detailKey), findsOneWidget); - router.pop(); - await tester.pumpAndSettle(); + router.pop(); + await tester.pumpAndSettle(); - // Should still be on the detail page. - expect(find.byKey(detailKey), findsOneWidget); - expect(find.byKey(homeKey), findsNothing); - }, - ); + // Should still be on the detail page. + expect(find.byKey(detailKey), findsOneWidget); + expect(find.byKey(homeKey), findsNothing); + }); }