Repository navigation
Description
Steps to reproduce
Using StatefulShellRoute.indexedStack with two branches:
- Start on branch A (
/home) - Switch to branch B (
/settings) viagoBranch(1) - Push a sub-route inside branch B (
/settings/general) - Switch back to branch A via
goBranch(0)— without popping the sub-route first - Press the system back button (or use the back gesture)
Expected results
The app closes, because branch A is at its root and there is nothing to pop.
Actual results
The back press is silently consumed. popRoute() returns true, nothing on screen changes,
and the app can never be closed with back again. Pressing back any number of times does nothing.
The app is permanently stuck: the only way out is the home button or the recents switcher.
The state never drains — branch B keeps its sub-route, so every later back press is swallowed too.
Confirmed on a real device (Android 16 emulator, API 36, gesture navigation) with both the
back button and the predictive-back edge swipe. Also reproduced as a widget test (below).
Root cause
_buildPageForShellRoute in packages/go_router/lib/src/builder.dart wraps every shell
Navigator in a PopScope:
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(...),
);With StatefulShellRoute.indexedStack, every branch stays mounted inside the IndexedStack
(Offstage + TickerMode, see _IndexedStackedRouteBranchContainer). So the PopScope
belonging to an inactive branch stays mounted and stays registered as a PopEntry on the
enclosing ModalRoute — which is the route on the root navigator that hosts the whole shell.
Branch B still has 2 matches, so its PopScope has canPop: false. And ModalRoute.popDisposition
uses every:
final bool canPopFromPopEntries = _popEntries.every((e) => e.canPopNotifier.value);
if (!canPopFromPopEntries) return RoutePopDisposition.doNotPop;One inactive branch with canPop: false therefore makes the root route report doNotPop,
regardless of which branch the user is actually looking at.
The observable symptom of this is a root navigator that contradicts itself:
rootNav.canPop() == false
rootNav.maybePop() == true // doNotPop -> onPopInvokedWithResult -> returns true
maybePop() returning true makes GoRouterDelegate.popRoute() return true, so the framework
reports the back as handled and the platform never closes the app. Nothing visible happens, because
doNotPop only fires onPopInvokedWithResult, and no PopScope in user code is listening.
Because the same notification path drives SystemNavigator.setFrameworkHandlesBack, the platform is
also told the framework handles back, so Android hands the gesture to Flutter instead of finishing
the activity.
Confirmation
Patching only that one line in a local copy of go_router 17.5.0:
canPop: true, // was: match.matches.length == 1changes the repro below from popRoute=true (swallowed, 3/3 presses) to popRoute=false
(app closes, 3/3 presses). Nothing else was changed.
I am not proposing canPop: true as the fix — it removes the protection the guard exists for.
It only isolates the cause.
Note on the TODO
The TODO points at #152330 as the condition for removing
this workaround. That PR is still a draft and was never merged, so the workaround is currently
permanent.
Suggested direction
The guard should apply only to the branch the user is actually in. An inactive branch of a
StatefulShellRoute should not be able to veto a pop on the root route — its stack is preserved
state, not the current navigation stack.
One option is for StatefulNavigationShell to expose the active branch index to the shell's
navigatorBuilder, so the guard becomes something like
canPop: match.matches.length == 1 || !isActiveBranch.
Code sample — widget test
Expand
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
void main() {
testWidgets('back is swallowed after leaving a branch with a sub-route', (tester) async {
final router = GoRouter(
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
builder: (_, __, shell) => Scaffold(
body: shell,
bottomNavigationBar: Row(children: [
TextButton(
key: const Key('toHome'),
onPressed: () => shell.goBranch(0),
child: const Text('home'),
),
TextButton(
key: const Key('toSettings'),
onPressed: () => shell.goBranch(1),
child: const Text('settings'),
),
]),
),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/home', builder: (_, __) => const Text('HOME')),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/settings',
builder: (c, __) => TextButton(
key: const Key('toGeneral'),
onPressed: () => c.go('/settings/general'),
child: const Text('SETTINGS'),
),
routes: [
GoRoute(path: 'general', builder: (_, __) => const Text('GENERAL')),
],
),
]),
],
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('toSettings')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('toGeneral'))); // sub-route inside branch B
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('toHome'))); // leave branch B, sub-route still on it
await tester.pumpAndSettle();
// On branch A's root nothing can be popped, so this must be false.
expect(await tester.binding.handlePopRoute(), isFalse); // FAILS: returns true
});
}Versions
- go_router: reproduced on 16.2.4, 17.5.0 (18.0.1 not testable here — it needs Dart >= 3.12)
- Flutter 3.38.5 stable, Dart 3.10.x
- Android 16 (API 36) emulator, gesture navigation,
targetSdkVersion 36
_findCurrentNavigators and popRoute in delegate.dart are unchanged on main, and no
CHANGELOG entry between 17.0.0 and 18.0.1 touches this.
Activity
fix(router): keep the back button working after a settings sub-page
suryaKommana2662 commentedon Sep 22, 2026
@veto9292 Thanks for the report. I was able to reproduce the issue locally using the provided widget test. After navigating from /home → /settings → /settings/general → /home, tester.binding.handlePopRoute() returned true instead of the expected false. Routing this to the
team-ecosystemfor further investigation.Related issues: #188018
Flutter Doctor Output
[go_router] Stop inactive StatefulShellRoute branches from vetoing pops
[go_router] Stop inactive StatefulShellRoute branches from vetoing po…