Skip to content

[go_router] Back button stops working app-wide after leaving a StatefulShellRoute branch that has sub-routes #193098

Closed
flutter/packages
#12972

Description

@veto9292

Steps to reproduce

Using StatefulShellRoute.indexedStack with two branches:

  1. Start on branch A (/home)
  2. Switch to branch B (/settings) via goBranch(1)
  3. Push a sub-route inside branch B (/settings/general)
  4. Switch back to branch A via goBranch(0) — without popping the sub-route first
  5. 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 == 1

changes 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

  1. suryaKommana2662 commented on Sep 22, 2026

    @suryaKommana2662
    Contributor

    @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-ecosystem for further investigation.

    Related issues: #188018

    00:04 +0: back is swallowed after leaving a branch with a sub-route                                                                                                   
    ══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
    The following TestFailure was thrown running a test:
    Expected: false
      Actual: <true>
    
    When the exception was thrown, this was the stack:
    #4      main.<anonymous closure> (file:///Users/suryakommana/StudioProjects/counter/test/widget_test.dart:87:7)
    <asynchronous suspension>
    #5      testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:192:15)
    <asynchronous suspension>
    #6      TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1953:5)
    <asynchronous suspension>
    <asynchronous suspension>
    (elided one frame from package:stack_trace)
    
    This was caught by the test expectation on the following line:
      file:///Users/suryakommana/StudioProjects/counter/test/widget_test.dart line 87
    The test description was:
      back is swallowed after leaving a branch with a sub-route

    Flutter Doctor Output

    [✓] Flutter (Channel stable, 3.47.5, on macOS 27.0 26A428 darwin-arm64, locale en-IN) [238ms]
        • Flutter version 3.47.5 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 6a19cca564 (4 days ago), 2026-09-17 14:13:22 -0400
        • Engine revision af7e796e16
        • Dart version 3.13.4
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,451ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.0.0
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 27.0) [630ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 27A266a
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [4ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [5.9s]
        • iPhone 18 Pro (mobile) • D80F2104-E911-4251-BEDD-E67D9B7EBF58 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-27-0 (simulator)
        • macOS (desktop)        • macos                                • darwin-arm64   • macOS 27.0 26A428 darwin-arm64
        • Chrome (web)           • chrome                               • web-javascript • Google Chrome 153.0.8010.53
    
    [✓] Network resources [460ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    a: tests"flutter test", flutter_test, or one of our tests
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Sep 22, 2026
  3. added a commit that references this issue on Sep 23, 2026
    01aa7fc
  4. added
    P2Important issues not at the top of the work list
    on Sep 29, 2026
  5. added a commit that references this issue on Nov 4, 2026
    3c7764b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: tests"flutter test", flutter_test, or one of our testshas reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: go_routerThe go_router packageteam-ecosystemOwned by Ecosystem teamtriaged-ecosystemTriaged by Ecosystem team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions