[go_router] Fix ShellRoute chrome dropped from semantics tree by ModalBarrier (#12353)
Shell chrome painted before a `ShellRoute` or `StatefulShellRoute` navigator (a side rail, or an app bar in a `Row`/`Column` based shell) disappears from the semantics tree. Screen readers cannot reach it at all: the nodes are not merely unnamed, they do not exist.
The mechanism: every `ModalRoute` builds a `ModalBarrier` wrapped in `BlockSemantics`, which drops the semantics of everything painted before it up to the nearest semantics boundary. The nested `Navigator` that go_router builds for shell routes does not establish such a boundary, so the block escapes the shell's navigator and prunes the shell's own chrome. Bottom-nav shells are unaffected only because `Scaffold` happens to paint its body before its bars.
This PR wraps the navigator built for `ShellRoute`/`StatefulShellRoute` branches in `Semantics(container: true)`, which contains the block. The root navigator is left unwrapped, since it has no earlier-painted siblings by construction. This is the workaround a framework team member confirmed on the linked issue; applying it inside go_router fixes it for every shell consumer without app-side patches.
Semantics tree of a minimal repro (a `Row` shell: 220px sidebar with three nav buttons, routed content on the right), before and after, captured with `debugDumpSemanticsTree`:
<details>
<summary>Before: 6 nodes, the entire sidebar subtree is missing</summary>
```
SemanticsNode#0
│ Rect.fromLTRB(0.0, 0.0, 2400.0, 1800.0)
│
└─SemanticsNode#1
│ Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) scaled by 3.0x
│ textDirection: ltr
│ sortKey: OrdinalSortKey#39327(order: 0.0)
│
└─SemanticsNode#2
│ Rect.fromLTRB(0.0, 0.0, 800.0, 600.0)
│ flags: scopesRoute
│
└─SemanticsNode#3
│ Rect.fromLTRB(221.0, 0.0, 800.0, 600.0)
│ sortKey: OrdinalSortKey#39327(order: 0.0)
│
└─SemanticsNode#4
│ Rect.fromLTRB(0.0, 0.0, 579.0, 600.0)
│ flags: scopesRoute
│
└─SemanticsNode#5
Rect.fromLTRB(85.5, 284.0, 493.5, 316.0)
label: "Dashboard content"
textDirection: ltr
```
Node `#3` starts at `x=221`, right of the 220px sidebar plus a 1px divider. There is no node anywhere for the sidebar: no title, no navigation container, no buttons. The sidebar is painted before the `/dashboard` route (`#4`, `scopesRoute`) inside the same enclosing semantics scope, which is exactly what that route's `BlockSemantics` drops.
</details>
<details>
<summary>After: sidebar fully present, routed content unchanged (two nodes for the repro's own toggle switch omitted for brevity)</summary>
```
SemanticsNode#0
│ Rect.fromLTRB(0.0, 0.0, 2400.0, 1800.0)
│
└─SemanticsNode#1
│ Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) scaled by 3.0x
│ textDirection: ltr
│ sortKey: OrdinalSortKey#39327(order: 0.0)
│
└─SemanticsNode#2
│ Rect.fromLTRB(0.0, 0.0, 800.0, 600.0)
│ flags: scopesRoute
│
├─SemanticsNode#3
│ Rect.fromLTRB(16.0, 16.0, 204.0, 76.0)
│ label: "Nested Navigator Semantics"
│ textDirection: ltr
│
├─SemanticsNode#4
│ │ Rect.fromLTRB(0.0, 92.0, 220.0, 260.0)
│ │ label: "Main navigation"
│ │ textDirection: ltr
│ │
│ ├─SemanticsNode#5
│ │ Rect.fromLTRB(0.0, 0.0, 220.0, 56.0)
│ │ actions: focus, tap
│ │ flags: isSelected, isButton, hasEnabledState, isEnabled,
│ │ isFocusable, hasSelectedState
│ │ label: "Dashboard"
│ │ textDirection: ltr
│ │
│ ├─SemanticsNode#6
│ │ Rect.fromLTRB(0.0, 56.0, 220.0, 112.0)
│ │ actions: focus, tap
│ │ flags: isButton, hasEnabledState, isEnabled, isFocusable,
│ │ hasSelectedState
│ │ label: "Settings"
│ │ textDirection: ltr
│ │
│ └─SemanticsNode#7
│ Rect.fromLTRB(0.0, 112.0, 220.0, 168.0)
│ actions: focus, tap
│ flags: isButton, hasEnabledState, isEnabled, isFocusable,
│ hasSelectedState
│ label: "Reports"
│ textDirection: ltr
│
└─SemanticsNode#10
│ Rect.fromLTRB(221.0, 0.0, 800.0, 600.0)
│ sortKey: OrdinalSortKey#39327(order: 0.0)
│
└─SemanticsNode#11
│ Rect.fromLTRB(0.0, 0.0, 579.0, 600.0)
│ flags: scopesRoute
│
└─SemanticsNode#12
Rect.fromLTRB(85.5, 284.0, 493.5, 316.0)
label: "Dashboard content"
textDirection: ltr
```
The routed content node ("Dashboard content") is byte-identical in both dumps. The fix does not change the routed content's semantics, only whether the chrome painted before the shell navigator survives alongside it.
</details>
Notes for review:
- Tests: the fix commit adds a `Shell navigator semantics boundary` group to `builder_test.dart` (chrome survives, structural wrap present, root navigator not wrapped), and a second commit adds a `StatefulShellRoute.indexedStack` regression test covering branch switching. Removing the wrap makes the chrome tests fail with `Found 0 widgets with a semantics label`.
- Version/CHANGELOG: go_router uses batch release, so this PR adds a file under `pending_changelogs/` (`version: patch`) instead of touching `pubspec.yaml` or `CHANGELOG.md`.
- Interaction with flutter/flutter#181519 (replacing `BlockSemantics` in modal routes with `AccessibilityFocusBlockType.blockSubtree`): the fix establishes a semantics container boundary at the shell navigator, which is where a nested navigator should scope its routes' blocking regardless of the blocking mechanism. If that migration later makes the containment unnecessary, the wrap stays harmless.
Fixes https://github.com/flutter/flutter/issues/135656
Related: https://github.com/flutter/flutter/issues/55758, https://github.com/flutter/flutter/issues/150978
## Pre-Review Checklist
[^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart
index e586626..e993df6 100644
--- a/packages/go_router/lib/src/builder.dart
+++ b/packages/go_router/lib/src/builder.dart
@@ -137,11 +137,25 @@
required this.errorBuilder,
required this.errorPageBuilder,
required this.requestFocus,
+ this.isShellNavigator = false,
});
final GlobalKey<NavigatorState> navigatorKey;
final List<NavigatorObserver> observers;
+ /// Whether this navigator builds the nested Navigator for a
+ /// [ShellRoute]/[StatefulShellRoute] branch, as opposed to the root
+ /// [GoRouter] navigator.
+ ///
+ /// Shell navigators are wrapped in `Semantics(container: true)` so that
+ /// each route's [ModalBarrier] (which blocks the semantics of
+ /// previously-painted siblings up to the nearest semantics boundary)
+ /// cannot reach past the shell's Navigator and drop shell chrome that
+ /// paints before it (e.g. a side rail or app bar in a `Row`/`Column`
+ /// shell). The root navigator has no earlier-painted siblings by
+ /// construction, so it does not need the same containment.
+ final bool isShellNavigator;
+
/// The actual [RouteMatchBase]s to be built.
///
/// This can be different from matches in [matchList] if this widget is used
@@ -315,6 +329,7 @@
errorBuilder: widget.errorBuilder,
errorPageBuilder: widget.errorPageBuilder,
requestFocus: widget.requestFocus,
+ isShellNavigator: true,
),
);
},
@@ -443,18 +458,24 @@
_updatePages(context);
}
assert(_pages != null);
+ final navigator = Navigator(
+ key: widget.navigatorKey,
+ requestFocus: widget.requestFocus,
+ restorationScopeId: widget.navigatorRestorationId,
+ pages: _pages!,
+ observers: widget.observers,
+ onPopPage: _handlePopPage,
+ );
return GoRouterStateRegistryScope(
registry: _registry,
child: HeroControllerScope(
controller: _controller!,
- child: Navigator(
- key: widget.navigatorKey,
- requestFocus: widget.requestFocus,
- restorationScopeId: widget.navigatorRestorationId,
- pages: _pages!,
- observers: widget.observers,
- onPopPage: _handlePopPage,
- ),
+ // A Navigator does not establish a semantics boundary, so a route's
+ // ModalBarrier (wrapped in BlockSemantics) can otherwise drop the
+ // semantics of shell chrome painted before this navigator (e.g. a
+ // side rail in a Row-based ShellRoute shell). See
+ // https://github.com/flutter/flutter/issues/135656.
+ child: widget.isShellNavigator ? Semantics(container: true, child: navigator) : navigator,
),
);
}
diff --git a/packages/go_router/pending_changelogs/shellroute_navigator_semantics_boundary.yaml b/packages/go_router/pending_changelogs/shellroute_navigator_semantics_boundary.yaml
new file mode 100644
index 0000000..04521ec
--- /dev/null
+++ b/packages/go_router/pending_changelogs/shellroute_navigator_semantics_boundary.yaml
@@ -0,0 +1,3 @@
+changelog: |
+ - Fixes `ShellRoute`/`StatefulShellRoute` shell chrome (e.g. a side rail or app bar painted before the routed child) being dropped from the semantics tree by the active route's `ModalBarrier`.
+version: patch
diff --git a/packages/go_router/test/builder_test.dart b/packages/go_router/test/builder_test.dart
index 26dd65f..4b31ebb 100644
--- a/packages/go_router/test/builder_test.dart
+++ b/packages/go_router/test/builder_test.dart
@@ -380,6 +380,208 @@
final Navigator navigator = tester.widget<Navigator>(find.byType(Navigator));
expect(navigator.requestFocus, isFalse);
});
+
+ group('Shell navigator semantics boundary', () {
+ testWidgets('Chrome painted before a ShellRoute Navigator stays in the '
+ 'semantics tree alongside routed content', (WidgetTester tester) async {
+ final SemanticsHandle semantics = tester.ensureSemantics();
+
+ final router = GoRouter(
+ initialLocation: '/a',
+ routes: <RouteBase>[
+ ShellRoute(
+ builder: (BuildContext context, GoRouterState state, Widget child) {
+ return Row(
+ children: <Widget>[
+ Semantics(
+ container: true,
+ label: 'chrome',
+ child: const SizedBox(width: 40, height: 40),
+ ),
+ Expanded(child: child),
+ ],
+ );
+ },
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content-a');
+ },
+ ),
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content-b');
+ },
+ ),
+ ],
+ ),
+ ],
+ );
+ addTearDown(router.dispose);
+
+ await tester.pumpWidget(MaterialApp.router(routerConfig: router));
+ await tester.pumpAndSettle();
+
+ // Without the Semantics(container: true) wrap around the shell's
+ // Navigator, the route's ModalBarrier (BlockSemantics) drops the
+ // chrome painted before it, because a Navigator does not
+ // establish a semantics boundary. See
+ // https://github.com/flutter/flutter/issues/135656.
+ expect(find.bySemanticsLabel('chrome'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-a'), findsOneWidget);
+
+ // Navigation between two routes within the same shell keeps
+ // working, and the chrome remains exposed after the rebuild.
+ router.go('/b');
+ await tester.pumpAndSettle();
+
+ expect(find.bySemanticsLabel('chrome'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-b'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-a'), findsNothing);
+
+ semantics.dispose();
+ });
+
+ testWidgets('ShellRoute Navigator is wrapped in a Semantics(container: true) '
+ 'boundary', (WidgetTester tester) async {
+ final shellNavigatorKey = GlobalKey<NavigatorState>();
+ final router = GoRouter(
+ initialLocation: '/',
+ routes: <RouteBase>[
+ ShellRoute(
+ navigatorKey: shellNavigatorKey,
+ builder: (BuildContext context, GoRouterState state, Widget child) {
+ return child;
+ },
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content');
+ },
+ ),
+ ],
+ ),
+ ],
+ );
+ addTearDown(router.dispose);
+
+ await tester.pumpWidget(MaterialApp.router(routerConfig: router));
+ await tester.pumpAndSettle();
+
+ final Iterable<Semantics> ancestorSemantics = tester.widgetList<Semantics>(
+ find.ancestor(of: find.byKey(shellNavigatorKey), matching: find.byType(Semantics)),
+ );
+ expect(
+ ancestorSemantics.first.container,
+ isTrue,
+ reason:
+ 'The nearest Semantics ancestor of the shell Navigator '
+ 'should be the container boundary added by go_router.',
+ );
+ });
+
+ testWidgets('Root GoRouter Navigator is not wrapped in an extra Semantics '
+ 'container boundary', (WidgetTester tester) async {
+ final rootNavigatorKey = GlobalKey<NavigatorState>();
+ final router = GoRouter(
+ navigatorKey: rootNavigatorKey,
+ initialLocation: '/',
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content');
+ },
+ ),
+ ],
+ );
+ addTearDown(router.dispose);
+
+ await tester.pumpWidget(MaterialApp.router(routerConfig: router));
+ await tester.pumpAndSettle();
+
+ // The root navigator has no earlier-painted siblings by
+ // construction, so go_router must not add the shell-only
+ // Semantics(container: true) wrap around it.
+ final Finder wrapper = find.ancestor(
+ of: find.byKey(rootNavigatorKey),
+ matching: find.byWidgetPredicate(
+ (Widget widget) => widget is Semantics && widget.container,
+ ),
+ );
+ expect(wrapper, findsNothing);
+ });
+
+ testWidgets('Chrome painted before a StatefulShellRoute Navigator stays in the '
+ 'semantics tree alongside routed content', (WidgetTester tester) async {
+ final SemanticsHandle semantics = tester.ensureSemantics();
+ StatefulNavigationShell? navigationShell;
+
+ final router = GoRouter(
+ initialLocation: '/a',
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state, StatefulNavigationShell shell) {
+ navigationShell = shell;
+ return Column(
+ children: <Widget>[
+ Semantics(container: true, label: 'chrome', child: const SizedBox(height: 10)),
+ Expanded(child: shell),
+ ],
+ );
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content-a');
+ },
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Text('content-b');
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+ addTearDown(router.dispose);
+
+ await tester.pumpWidget(MaterialApp.router(routerConfig: router));
+ await tester.pumpAndSettle();
+
+ // Without the Semantics(container: true) wrap around the shell
+ // branch's Navigator, the route's ModalBarrier (BlockSemantics)
+ // drops the chrome painted before it, because a Navigator does not
+ // establish a semantics boundary. See
+ // https://github.com/flutter/flutter/issues/135656.
+ expect(find.bySemanticsLabel('chrome'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-a'), findsOneWidget);
+
+ // Switching branches keeps the chrome exposed after the rebuild.
+ navigationShell!.goBranch(1);
+ await tester.pumpAndSettle();
+
+ expect(find.bySemanticsLabel('chrome'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-b'), findsOneWidget);
+ expect(find.bySemanticsLabel('content-a'), findsNothing);
+
+ semantics.dispose();
+ });
+ });
});
}