From 6a8c49e1410432beeeb414793539d5470d02775a Mon Sep 17 00:00:00 2001 From: LukasMirbt Date: Sun, 7 Sep 2025 10:44:36 +0200 Subject: [PATCH 1/3] Fix issue where ShellRoutes break iOS swipe back navigation --- packages/go_router/CHANGELOG.md | 4 + packages/go_router/lib/src/builder.dart | 33 ++-- packages/go_router/pubspec.yaml | 2 +- .../shell_route_ios_back_gesture_test.dart | 133 +++++++++++++++++ ...ful_shell_route_ios_back_gesture_test.dart | 141 ++++++++++++++++++ 5 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 packages/go_router/test/shell_route_ios_back_gesture_test.dart create mode 100644 packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index af98dbd58fa..5602343504e 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 16.2.2 + +- Fixes an issue where iOS back gesture pops entire ShellRoute instead of the active sub-route. + ## 16.2.1 - Adds state restoration topic to documentation. diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 2811247f576..d7da2abf890 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -293,20 +293,25 @@ class _CustomNavigatorState extends State<_CustomNavigator> { List? observers, String? restorationScopeId, ) { - return _CustomNavigator( - // The state needs to persist across rebuild. - key: GlobalObjectKey(navigatorKey.hashCode), - navigatorRestorationId: restorationScopeId, - navigatorKey: navigatorKey, - matches: match.matches, - matchList: matchList, - configuration: widget.configuration, - observers: observers ?? const [], - onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch, - // This is used to recursively build pages under this shell route. - errorBuilder: widget.errorBuilder, - errorPageBuilder: widget.errorPageBuilder, - requestFocus: widget.requestFocus, + return PopScope( + // Prevent ShellRoute from being popped, for example + // by an iOS back gesture, when the route has active sub-routes. + canPop: match.matches.length == 1, + child: _CustomNavigator( + // The state needs to persist across rebuild. + key: GlobalObjectKey(navigatorKey.hashCode), + navigatorRestorationId: restorationScopeId, + navigatorKey: navigatorKey, + matches: match.matches, + matchList: matchList, + configuration: widget.configuration, + observers: observers ?? const [], + onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch, + // This is used to recursively build pages under this shell route. + errorBuilder: widget.errorBuilder, + errorPageBuilder: widget.errorPageBuilder, + requestFocus: widget.requestFocus, + ), ); }, ); diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 41e843ef57d..08eef5c2530 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: 16.2.1 +version: 16.2.2 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/shell_route_ios_back_gesture_test.dart b/packages/go_router/test/shell_route_ios_back_gesture_test.dart new file mode 100644 index 00000000000..8df74473364 --- /dev/null +++ b/packages/go_router/test/shell_route_ios_back_gesture_test.dart @@ -0,0 +1,133 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +// Regression test for https://github.com/flutter/flutter/issues/120353 +void main() { + group('iOS back gesture inside a ShellRoute', () { + Future backGesture(WidgetTester tester) async { + await tester.dragFrom(const Offset(0, 300), const Offset(500, 300)); + } + + testWidgets('pops the top sub-route ' + 'when there is an active sub-route', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Comment'), findsOneWidget); + + await backGesture(tester); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('pops ShellRoute ' + 'when there are no active sub-routes', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await backGesture(tester); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + }); +} + +class _TestApp extends StatefulWidget { + const _TestApp(); + + @override + State<_TestApp> createState() => _TestAppState(); +} + +class _TestAppState extends State<_TestApp> { + final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Center( + child: FilledButton( + onPressed: () { + GoRouter.of(context).go('/post'); + }, + child: const Text('Go to Post'), + ), + ), + ); + }, + routes: [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return child; + }, + routes: [ + GoRoute( + path: '/post', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Post')), + body: Center( + child: FilledButton( + onPressed: () { + GoRouter.of(context).go('/post/comment'); + }, + child: const Text('Comment'), + ), + ), + ); + }, + routes: [ + GoRoute( + path: 'comment', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Comment')), + ); + }, + ), + ], + ), + ], + ), + ], + ), + ], + ); + + @override + void dispose() { + _router.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router(routerConfig: _router); + } +} diff --git a/packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart b/packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart new file mode 100644 index 00000000000..e9a3dbd1c88 --- /dev/null +++ b/packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart @@ -0,0 +1,141 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +// Regression test for https://github.com/flutter/flutter/issues/120353 +void main() { + group('iOS back gesture inside a StatefulShellRoute', () { + Future backGesture(WidgetTester tester) async { + await tester.dragFrom(const Offset(0, 300), const Offset(500, 300)); + } + + testWidgets('pops the top sub-route ' + 'when there is an active sub-route', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Comment'), findsOneWidget); + + await backGesture(tester); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('pops StatefulShellRoute ' + 'when there are no active sub-routes', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await backGesture(tester); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + }); +} + +class _TestApp extends StatefulWidget { + const _TestApp(); + + @override + State<_TestApp> createState() => _TestAppState(); +} + +class _TestAppState extends State<_TestApp> { + final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Center( + child: FilledButton( + onPressed: () { + GoRouter.of(context).go('/post'); + }, + child: const Text('Go to Post'), + ), + ), + ); + }, + routes: [ + StatefulShellRoute.indexedStack( + builder: ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + return navigationShell; + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/post', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Post')), + body: Center( + child: FilledButton( + onPressed: () { + GoRouter.of(context).go('/post/comment'); + }, + child: const Text('Comment'), + ), + ), + ); + }, + routes: [ + GoRoute( + path: 'comment', + builder: (BuildContext context, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Comment')), + ); + }, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + @override + void dispose() { + _router.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router(routerConfig: _router); + } +} From c3c3f87f2f439466f223d6c6d6db61e95cfb3883 Mon Sep 17 00:00:00 2001 From: LukasMirbt Date: Fri, 19 Sep 2025 21:30:57 +0200 Subject: [PATCH 2/3] Add tests for Android back button --- ...dart => shell_route_system_back_test.dart} | 44 +++++++++++++++--- ...tateful_shell_route_system_back_test.dart} | 46 ++++++++++++++++--- packages/go_router/test/test_helpers.dart | 4 ++ 3 files changed, 82 insertions(+), 12 deletions(-) rename packages/go_router/test/{shell_route_ios_back_gesture_test.dart => shell_route_system_back_test.dart} (74%) rename packages/go_router/test/{stateful_shell_route_ios_back_gesture_test.dart => stateful_shell_route_system_back_test.dart} (75%) diff --git a/packages/go_router/test/shell_route_ios_back_gesture_test.dart b/packages/go_router/test/shell_route_system_back_test.dart similarity index 74% rename from packages/go_router/test/shell_route_ios_back_gesture_test.dart rename to packages/go_router/test/shell_route_system_back_test.dart index 8df74473364..94a381adaa2 100644 --- a/packages/go_router/test/shell_route_ios_back_gesture_test.dart +++ b/packages/go_router/test/shell_route_system_back_test.dart @@ -7,13 +7,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'test_helpers.dart'; + // Regression test for https://github.com/flutter/flutter/issues/120353 void main() { group('iOS back gesture inside a ShellRoute', () { - Future backGesture(WidgetTester tester) async { - await tester.dragFrom(const Offset(0, 300), const Offset(500, 300)); - } - testWidgets('pops the top sub-route ' 'when there is an active sub-route', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -29,7 +27,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Comment'), findsOneWidget); - await backGesture(tester); + await simulateIosBackGesture(tester); await tester.pumpAndSettle(); expect(find.text('Post'), findsOneWidget); @@ -47,13 +45,47 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Post'), findsOneWidget); - await backGesture(tester); + await simulateIosBackGesture(tester); await tester.pumpAndSettle(); expect(find.text('Home'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); }); + + group('Android back button inside a ShellRoute', () { + testWidgets('pops the top sub-route ' + 'when there is an active sub-route', (WidgetTester tester) async { + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Comment'), findsOneWidget); + + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + }); + + testWidgets('pops ShellRoute ' + 'when there are no active sub-routes', (WidgetTester tester) async { + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + }); + }); } class _TestApp extends StatefulWidget { diff --git a/packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart b/packages/go_router/test/stateful_shell_route_system_back_test.dart similarity index 75% rename from packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart rename to packages/go_router/test/stateful_shell_route_system_back_test.dart index e9a3dbd1c88..4d728964459 100644 --- a/packages/go_router/test/stateful_shell_route_ios_back_gesture_test.dart +++ b/packages/go_router/test/stateful_shell_route_system_back_test.dart @@ -7,13 +7,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'test_helpers.dart'; + // Regression test for https://github.com/flutter/flutter/issues/120353 void main() { group('iOS back gesture inside a StatefulShellRoute', () { - Future backGesture(WidgetTester tester) async { - await tester.dragFrom(const Offset(0, 300), const Offset(500, 300)); - } - testWidgets('pops the top sub-route ' 'when there is an active sub-route', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -29,7 +27,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Comment'), findsOneWidget); - await backGesture(tester); + await simulateIosBackGesture(tester); await tester.pumpAndSettle(); expect(find.text('Post'), findsOneWidget); @@ -47,13 +45,49 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Post'), findsOneWidget); - await backGesture(tester); + await simulateIosBackGesture(tester); await tester.pumpAndSettle(); expect(find.text('Home'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); }); + + group('Android back button inside a StatefulShellRoute', () { + testWidgets('pops the top sub-route ' + 'when there is an active sub-route', (WidgetTester tester) async { + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Comment'), findsOneWidget); + + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('pops StatefulShellRoute ' + 'when there are no active sub-routes', (WidgetTester tester) async { + await tester.pumpWidget(const _TestApp()); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.text('Post'), findsOneWidget); + + await simulateAndroidBackButton(tester); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + }); + }); } class _TestApp extends StatefulWidget { diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index d1d11673c9c..553acbb87ad 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -362,6 +362,10 @@ Future simulateAndroidBackButton(WidgetTester tester) async { ); } +Future simulateIosBackGesture(WidgetTester tester) async { + await tester.dragFrom(const Offset(0, 300), const Offset(500, 300)); +} + GoRouterPageBuilder createPageBuilder({ String? restorationId, required Widget child, From a844fdec1400f14c58a0e1ec085bfaa4c53a89ee Mon Sep 17 00:00:00 2001 From: LukasMirbt Date: Fri, 19 Sep 2025 22:21:43 +0200 Subject: [PATCH 3/3] Add TODO --- packages/go_router/lib/src/builder.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index d7da2abf890..5918abb4abd 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -296,6 +296,8 @@ class _CustomNavigatorState extends State<_CustomNavigator> { return PopScope( // Prevent ShellRoute from being popped, for example // by an iOS back gesture, when the route has active sub-routes. + // TODO(LukasMirbt): Remove when minimum flutter version includes + // https://github.com/flutter/flutter/pull/152330. canPop: match.matches.length == 1, child: _CustomNavigator( // The state needs to persist across rebuild.