From 293106dcc31208ca58ba769c8dc76190277b322a Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Fri, 3 Apr 2026 15:39:42 -0700 Subject: [PATCH] Fix StatefulShellRoute PopScope behavior to respect back button (#181945) --- packages/go_router/lib/src/delegate.dart | 1 - packages/go_router/lib/src/route.dart | 10 +- .../change_2026_04_03_1775255636284.yaml | 3 + .../test/shell_route_pop_scope_test.dart | 186 ++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 packages/go_router/pending_changelogs/change_2026_04_03_1775255636284.yaml create mode 100644 packages/go_router/test/shell_route_pop_scope_test.dart diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 37e56987e358..770f988ecebe 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -124,7 +124,6 @@ class GoRouterDelegate extends RouterDelegate while (walker is ShellRouteMatch) { final NavigatorState potentialCandidate = walker.navigatorKey.currentState!; - final ModalRoute? modalRoute = ModalRoute.of( potentialCandidate.context, ); diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index cf1a7e240827..6fcc2e47147b 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -1569,7 +1569,15 @@ class StatefulNavigationShellState extends State ) .toList(); - return widget.containerBuilder(context, widget, children); + return PopScope( + canPop: widget.currentIndex == 0, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (!didPop && widget.currentIndex != 0) { + goBranch(0); + } + }, + child: widget.containerBuilder(context, widget, children), + ); } } diff --git a/packages/go_router/pending_changelogs/change_2026_04_03_1775255636284.yaml b/packages/go_router/pending_changelogs/change_2026_04_03_1775255636284.yaml new file mode 100644 index 000000000000..f9ab765ec0eb --- /dev/null +++ b/packages/go_router/pending_changelogs/change_2026_04_03_1775255636284.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fix StatefulShellRoute PopScope behavior to orchestrate back button pops to the root branch when inner navigators cannot pop. +version: patch diff --git a/packages/go_router/test/shell_route_pop_scope_test.dart b/packages/go_router/test/shell_route_pop_scope_test.dart new file mode 100644 index 000000000000..8896c8ffc1eb --- /dev/null +++ b/packages/go_router/test/shell_route_pop_scope_test.dart @@ -0,0 +1,186 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + testWidgets( + 'PopScope in StatefulShellRoute branch works on subsequent visits', + (WidgetTester tester) async { + int tabBPopCount = 0; + + final routes = [ + StatefulShellRoute.indexedStack( + builder: + ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (int index) => navigationShell.goBranch(index), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'A', + ), + BottomNavigationBarItem( + icon: Icon(Icons.business), + label: 'B', + ), + ], + ), + ); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tabA', + builder: (BuildContext context, GoRouterState state) => + const DummyScreen(key: ValueKey('tabA')), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tabB', + builder: (BuildContext context, GoRouterState state) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + tabBPopCount++; + if (!didPop) { + context.go('/tabA'); + } + }, + child: const DummyScreen(key: ValueKey('tabB')), + ); + }, + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/tabA', + ); + + // 1. Visit Tab A + expect(find.byKey(const ValueKey('tabA')), findsOneWidget); + expect(find.byKey(const ValueKey('tabB')), findsNothing); + + // 2. Switch to Tab B + router.go('/tabB'); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('tabB')), findsOneWidget); + + // 3. Press back button on Tab B (First Visit) + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + + // Should have switched back to Tab A + expect(find.byKey(const ValueKey('tabA')), findsOneWidget); + expect(tabBPopCount, 1); + + // 4. Switch to Tab B again (Second Visit) + router.go('/tabB'); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('tabB')), findsOneWidget); + + // 5. Press back button on Tab B (Second Visit) + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + + // Verify that the PopScope was triggered again + expect(tabBPopCount, 2); + + // Should have switched back to Tab A again + expect(find.byKey(const ValueKey('tabA')), findsOneWidget); + }, + ); + + testWidgets( + 'StatefulShellRoute switches to root branch by default on back button', + (WidgetTester tester) async { + final routes = [ + StatefulShellRoute.indexedStack( + builder: + ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (int index) => navigationShell.goBranch(index), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'A', + ), + BottomNavigationBarItem( + icon: Icon(Icons.business), + label: 'B', + ), + ], + ), + ); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tabA', + builder: (BuildContext context, GoRouterState state) => + const DummyScreen(key: ValueKey('tabA')), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tabB', + builder: (BuildContext context, GoRouterState state) => + const DummyScreen(key: ValueKey('tabB')), + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/tabA', + ); + + expect(find.byKey(const ValueKey('tabA')), findsOneWidget); + + router.go('/tabB'); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('tabB')), findsOneWidget); + + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('tabA')), findsOneWidget); + }, + ); +}