In a go_router app with StatefulShellRoute tabs, which Navigator does the Android back button pop, and why does it not return to the previous tab?
answer
- innermost current Navigator first
- walk outward to the root
- maybePop honours PopScope
- tab switches are not history entries
- nothing pops, platform decides
basics
~20 sgo_router's delegate tries maybePop on the innermost active Navigator first, the current branch's, then works outward to the root; a tab switch is not a page on any stack, so at a branch's root nothing pops and the platform's default back, leaving the app, applies.
solid answer
~50 sWhen Android sends a back event, `GoRouterDelegate.popRoute` builds the list of current Navigators: the root one and, walking the active match, each shell or branch Navigator whose route is current, stopping if a pageless route such as a dialog covers a shell. It calls `maybePop()` on them **innermost first**, so the active branch's top page is popped before anything outside it. If none pops, it tries the last route's `onExit` and otherwise reports the pop as unhandled, and Android backs out of the app. Switching tabs with `goBranch` changes which branch is shown but adds no page to any stack, so back never returns to the previous tab. If the product wants back at a non-home tab's root to go to Feed, I intercept it deliberately, for example with a `PopScope` in the shell that calls `goBranch(0)`.
code
dart · 31 linesimport 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class SocialShell extends StatelessWidget {
const SocialShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
final bool onFeed = navigationShell.currentIndex == 0;
return PopScope<Object?>(
// Reached only after the active branch Navigator declined to pop.
canPop: onFeed,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (!didPop) navigationShell.goBranch(0);
},
child: Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: navigationShell.goBranch,
destinations: const <Widget>[
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
),
);
}
}go deeper
Recall that back pops the active branch's top page first, and that at a branch root nothing pops and the app is left.
Explain the delegate's walk from root to the innermost current Navigator, why pops run innermost first, and why dialogs on the root close first.
Diagnose back that exits too early or pops the wrong stack by checking which Navigator each route and dialog lands on, and treat return-to-Home as explicit policy.
Decide the app's back-navigation contract across tabs, dialogs and full-screen flows, and keep it consistent with deep links and platform conventions.
## Many Navigators, one back button A go_router app with `StatefulShellRoute` tabs has a **root Navigator** and one **Navigator per branch**, plus one per `ShellRoute` if there are nested shells. Android's system back is a single event, so something has to decide which Navigator it affects. In go_router that decision is made by `GoRouterDelegate.popRoute`, which Flutter's `Router` calls through the root back button dispatcher. ## The algorithm 1. Collect the **current Navigators**: always the root Navigator, then, walking down the active match list, each shell or branch Navigator that is part of the current configuration. 2. Stop the walk early if a **pageless route**, such as a dialog shown on the root Navigator, is covering a shell: in that case only the Navigators above it are candidates, so back closes the dialog first. 3. Try `maybePop()` on each candidate **innermost first**: the active branch, then any outer shell, then the root. 4. The first Navigator that reports the event handled ends the process. `maybePop` respects each route's pop policy: a page at the bottom of its stack reports unhandled, so the walk moves outward, while a `PopScope` with `canPop: false` makes `maybePop` report the event handled without popping, so no outer Navigator is tried and the app stays open. 5. If nothing popped, go_router tries the last route's `onExit` callback. 6. Otherwise it returns `false`: the pop was not handled, and the platform applies its default, which on Android means leaving the app. `context.pop()` uses the same ordering: it pops the innermost current Navigator that can pop, and throws a `GoError` reading 'There is nothing to pop' if none can. ## What that means for a social app | Situation | Effect of back | |---|---| | Feed tab, a post open over the feed | the post is popped from the Feed branch Navigator | | Feed tab, photo on the root Navigator above the shell | the photo is popped from the root Navigator | | Profile tab at its root page | nothing pops; the app is left | | a dialog open over the shell | the dialog closes | | Search tab after switching from Feed | nothing pops; Feed is not restored | The last row is the classic surprise. `goBranch` switches the **visible branch** and, via `restore`, the current location. It does not push anything, so there is no page representing "the previous tab" on any stack. ## Making back return to Home Some products want back on a non-home tab's root to show the Feed tab before exiting. That is a policy decision the router does not make for you: - Wrap the shell body in a `PopScope` whose `canPop` is `true` only when the Feed branch is active, and whose `onPopInvokedWithResult` calls `navigationShell.goBranch(0)` when a pop was refused. - Because the delegate only reaches the root after every inner Navigator declined, this runs exactly when the active branch is at its root. - Keep it to one rule; a tab-history stack reimplemented by hand tends to disagree with deep links and restoration. ## Tracing one sequence 1. The user opens Feed, taps a post (branch stack: Feed, post), then opens its photo, placed on the root Navigator with `parentNavigatorKey`. 2. Back: the last match is the photo page on the root Navigator, so the walk never descends into the shell; the only candidate is the root, and it pops the photo. 3. Back: now the branch is current again; `maybePop` on the Feed branch pops the post. 4. Back: the Feed branch has only its root page and reports unhandled; the root Navigator has only the shell page and reports unhandled. 5. `popRoute` returns `false`, and Android leaves the app. ## Debugging surprises - **Back closes the app from a detail page**: the page was placed on the wrong Navigator, often because its route sits outside the shell, or because a `Navigator.of(context)` lookup from the shell builder's context reached the root Navigator instead of the branch's. - **Back pops a whole tab**: on iOS, older go_router releases had a bug where the back gesture popped the entire `ShellRoute`; 16.2.3 fixed it, so check the version before blaming the configuration. - **Back does nothing at a tab root and the app never exits**: a `PopScope` with `canPop: false` somewhere on the current chain is consuming the event, because a refused pop counts as handled and stops the walk.
- With go_router, why does back from a dialog close the dialog rather than the tab page beneath it?A dialog shown on the root Navigator is a pageless route covering the shell. When go_router collects the current Navigators, it stops walking into the shell once the shell's route is no longer the current one, so only the root Navigator is tried and it pops the dialog.
- With go_router, what does context.pop() do when no current Navigator can pop?It throws a `GoError` with the message 'There is nothing to pop'. Screens that may be opened directly by a deep link should check `context.canPop()` first and fall back to a `go` to a sensible parent location.
- With go_router, how would you make back on the Profile tab's root switch to the Feed tab?Intercept it in the shell: a `PopScope` whose `canPop` is true only on the Feed branch, with `onPopInvokedWithResult` calling `navigationShell.goBranch(0)` when the pop was refused. The branch Navigators pop first, so it runs only at a branch root.
saying these in an interview costs you the question
- Believes back returns to the previously selected tab by default.
- Thinks the root Navigator is always popped first.
- Expects context.pop() to exit the app quietly when nothing can pop.
- Assumes a PopScope is ignored by go_router's back handling.
- Believes each goBranch call pushes a history entry onto the root Navigator.