From 9a13e70537d69df58df791a4bcf7025dea1ec5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Mon, 20 Jul 2026 17:03:15 +0300 Subject: [PATCH 1/5] [go_router] Keep parent navigator pages when switching to an unloaded branch --- packages/go_router/lib/src/route.dart | 55 ++++++++++++++++++- .../change_2026_08_09_1786291908038.yaml | 3 + packages/go_router/test/go_router_test.dart | 55 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/go_router/pending_changelogs/change_2026_08_09_1786291908038.yaml diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index d6cd5a5143f5..6d1266be85ad 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1476,8 +1476,61 @@ class StatefulNavigationShellState extends State with R if (matchList != null && matchList.isNotEmpty) { _router.restore(matchList); } else { - _router.go(widget._effectiveInitialBranchLocation(index)); + final RouteMatchList? initialMatchList = _initialMatchListForBranch(index); + if (initialMatchList != null) { + _router.restore(initialMatchList); + } else { + _router.go(widget._effectiveInitialBranchLocation(index)); + } + } + } + + /// Builds the match list for the initial location of the branch at [index], + /// keeping the parts of the current match list that lie outside this shell + /// route. + /// + /// Switching to a branch without preserved state must not drop pages the + /// parent Navigators have below the shell route, so the shell match for the + /// initial branch location is grafted into the current match list instead + /// of navigating from scratch. See + /// https://github.com/flutter/flutter/issues/188295. + RouteMatchList? _initialMatchListForBranch(int index) { + final RouteMatchList initialMatchList = _router.configuration.findMatch( + Uri.parse(widget._effectiveInitialBranchLocation(index)), + ); + ShellRouteMatch? newShellMatch; + initialMatchList.visitRouteMatches((RouteMatchBase match) { + newShellMatch = match is ShellRouteMatch && match.route == route ? match : newShellMatch; + return newShellMatch == null; + }); + if (newShellMatch == null || initialMatchList.error != null) { + return null; } + + List replaceShellMatch(List matches) { + return matches.map((RouteMatchBase match) { + if (match is ShellRouteMatch) { + if (match.route == route) { + return newShellMatch!; + } + return match.copyWith(matches: replaceShellMatch(match.matches)); + } + return match; + }).toList(); + } + + final RouteMatchList currentMatchList = _scopedMatchList( + widget.shellRouteContext.routeMatchList, + ); + final List matches = replaceShellMatch(currentMatchList.matches); + return currentMatchList.copyWith( + matches: matches, + uri: initialMatchList.uri, + pathParameters: { + ...currentMatchList.pathParameters, + ...initialMatchList.pathParameters, + }, + ); } @override diff --git a/packages/go_router/pending_changelogs/change_2026_08_09_1786291908038.yaml b/packages/go_router/pending_changelogs/change_2026_08_09_1786291908038.yaml new file mode 100644 index 000000000000..3a6826f96054 --- /dev/null +++ b/packages/go_router/pending_changelogs/change_2026_08_09_1786291908038.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fixes pages on parent navigators being dropped when switching to a not yet loaded branch of a `StatefulShellRoute`. +version: patch diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 2b75eea24fd8..f317921ee004 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -3211,6 +3211,61 @@ void main() { expect(matches.pathParameters['pid'], pid); }); + testWidgets('StatefulShellRoute keeps parent navigator pages when switching ' + 'to an unloaded branch', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/188295 + StatefulNavigationShell? routeState; + final routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => const Text('Home'), + ), + StatefulShellRoute.indexedStack( + builder: + (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) { + routeState = navigationShell; + return navigationShell; + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) => const Text('Screen A'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) => const Text('Screen B'), + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + expect(find.text('Home'), findsOneWidget); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(find.text('Screen A'), findsOneWidget); + expect(router.canPop(), isTrue); + + routeState!.goBranch(1); + await tester.pumpAndSettle(); + expect(find.text('Screen B'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.uri.toString(), '/b'); + expect(router.canPop(), isTrue); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + }); + testWidgets('StatefulShellRoute preserve extra when switching branch', ( WidgetTester tester, ) async { From 3ea9e4b002176648d81de5d4ccedf35fbda5149c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Mon, 10 Aug 2026 06:51:50 +0300 Subject: [PATCH 2/5] [go_router] Do not carry extra into an unloaded branch Grafting the shell match for a branch's initial location kept the current match list's extra, so an object passed to the outer location reached a branch that never asked for it. Take the extra from the match list built for the initial location instead, which is what GoRouter.go would produce. --- packages/go_router/lib/src/route.dart | 5 +- packages/go_router/test/go_router_test.dart | 51 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 6d1266be85ad..3427b5c29dbd 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1523,9 +1523,12 @@ class StatefulNavigationShellState extends State with R widget.shellRouteContext.routeMatchList, ); final List matches = replaceShellMatch(currentMatchList.matches); - return currentMatchList.copyWith( + return RouteMatchList( matches: matches, uri: initialMatchList.uri, + // The branch is navigated to as if by [GoRouter.go], which carries no + // extra, so the object the outer location was given must not leak into it. + extra: initialMatchList.extra, pathParameters: { ...currentMatchList.pathParameters, ...initialMatchList.pathParameters, diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index f317921ee004..b02baa213734 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -3266,6 +3266,57 @@ void main() { expect(find.text('Home'), findsOneWidget); }); + testWidgets('StatefulShellRoute does not carry extra into an unloaded branch', ( + WidgetTester tester, + ) async { + StatefulNavigationShell? routeState; + Object? extraOnB; + final routes = [ + StatefulShellRoute.indexedStack( + builder: + (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) { + routeState = navigationShell; + return navigationShell; + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) => const Text('Screen A'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + extraOnB = state.extra; + return const Text('Screen B'); + }, + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/a', + initialExtra: Object(), + ); + expect(find.text('Screen A'), findsOneWidget); + + routeState!.goBranch(1); + await tester.pumpAndSettle(); + expect(find.text('Screen B'), findsOneWidget); + expect(extraOnB, isNull); + expect(router.routerDelegate.currentConfiguration.uri.toString(), '/b'); + }); + testWidgets('StatefulShellRoute preserve extra when switching branch', ( WidgetTester tester, ) async { From dbc67efceb2ce7337a140af38349675768ef6da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Mon, 10 Aug 2026 10:48:03 +0300 Subject: [PATCH 3/5] [go_router] Drop the unreachable error guard findMatch returns an error only when it matched nothing, and an empty match list already leaves newShellMatch null, so the second disjunct never decides the outcome. --- packages/go_router/lib/src/route.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 3427b5c29dbd..d98aee16729e 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1503,7 +1503,7 @@ class StatefulNavigationShellState extends State with R newShellMatch = match is ShellRouteMatch && match.route == route ? match : newShellMatch; return newShellMatch == null; }); - if (newShellMatch == null || initialMatchList.error != null) { + if (newShellMatch == null) { return null; } From fdf1a808848cd720be6e3cce0a4add93341cfc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Mon, 10 Aug 2026 18:13:14 +0300 Subject: [PATCH 4/5] [go_router] Reword two comments in the branch match list helper --- packages/go_router/lib/src/route.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index d98aee16729e..262bc22ea106 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1490,9 +1490,9 @@ class StatefulNavigationShellState extends State with R /// route. /// /// Switching to a branch without preserved state must not drop pages the - /// parent Navigators have below the shell route, so the shell match for the - /// initial branch location is grafted into the current match list instead - /// of navigating from scratch. See + /// parent Navigators have below the shell route. This grafts the shell match + /// for the initial branch location into the current match list instead of + /// navigating from scratch. See /// https://github.com/flutter/flutter/issues/188295. RouteMatchList? _initialMatchListForBranch(int index) { final RouteMatchList initialMatchList = _router.configuration.findMatch( @@ -1527,7 +1527,7 @@ class StatefulNavigationShellState extends State with R matches: matches, uri: initialMatchList.uri, // The branch is navigated to as if by [GoRouter.go], which carries no - // extra, so the object the outer location was given must not leak into it. + // extra. The object the outer location was given must not leak into it. extra: initialMatchList.extra, pathParameters: { ...currentMatchList.pathParameters, From 8a6a4401fe5f7b4e7f7358cedb6c39546b2d0b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Tue, 11 Aug 2026 04:39:03 +0300 Subject: [PATCH 5/5] use an explicit early return when finding the branch shell match --- packages/go_router/lib/src/route.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 262bc22ea106..ccd802cd69bd 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1500,8 +1500,11 @@ class StatefulNavigationShellState extends State with R ); ShellRouteMatch? newShellMatch; initialMatchList.visitRouteMatches((RouteMatchBase match) { - newShellMatch = match is ShellRouteMatch && match.route == route ? match : newShellMatch; - return newShellMatch == null; + if (match is ShellRouteMatch && match.route == route) { + newShellMatch = match; + return false; + } + return true; }); if (newShellMatch == null) { return null;