Skip to content

Use AccessibilityFocusBlockType.blockSubtree to replace BlockSemantics in modal routes. - #181519

Closed
hannah-hyj wants to merge 2 commits into
flutter:masterfrom
hannah-hyj:modal-barrier-focus
Closed

hannah-hyj wants to merge 2 commits into
flutter:masterfrom
hannah-hyj:modal-barrier-focus

Conversation

@hannah-hyj

@hannah-hyj hannah-hyj commented Jan 26, 2026 •

Copy link
Copy Markdown
Member

issue: #166258

The way BlockSemantics works : it drops those blocked semantics nodes from tree.
The way AccessibilityFocusBlockType works: the nodes are still in the semantics tree but with a flag "isAccessibilityFocusBlocked: true "

This will stop a live region node from being dropped from the tree and added to the tree again, and thus live region announcing things again.

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) f: routes Navigator, Router, and related APIs. labels Jan 26, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request successfully replaces BlockSemantics with AccessibilityFocusBlockType.blockSubtree in modal routes, which is a more granular and efficient way to manage accessibility focus. The changes to _SemanticsParentData for operator== and hashCode are correctly implemented to include the new accessibilityFocusBlockType field. The new test cases thoroughly validate the intended behavior of blocking accessibility focus for non-current modal routes and dialogs, and correctly verify the unblocking when routes are popped. Overall, the implementation is sound and well-tested.

@hannah-hyj
hannah-hyj requested a review from chunhtai January 27, 2026 21:01
focusScopeNode.canRequestFocus = !ignoreEvents;
return IgnorePointer(ignoring: ignoreEvents, child: child);
child: Semantics(
accessibilityFocusBlockType: widget.route.isCurrent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may also need to do this for modalbarrier

@hannah-hyj hannah-hyj closed this Feb 3, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Sep 22, 2026
…lBarrier (#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 flutter/flutter#135656
Related: flutter/flutter#55758, flutter/flutter#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.
vashworth pushed a commit to vashworth/packages that referenced this pull request Sep 24, 2026
…lBarrier (flutter#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 `flutter#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 (`flutter#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 flutter/flutter#135656
Related: flutter/flutter#55758, flutter/flutter#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants