[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();
+      });
+    });
   });
 }