fix: Screen Reader: Global: Focus is lost when returning to the previous screen#91752
Conversation
|
@marufsharifi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
cc @mkhutornyi |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
… dedup native teardown
This comment was marked as resolved.
This comment was marked as resolved.
…Reader-Global-Focus-is-lost-when-returning-to-the-previous-screen
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
…ndle leak, focusKey prop
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
…Reader-Global-Focus-is-lost-when-returning-to-the-previous-screen
Code Review (re-review @
|
Same as Codex. Invalid |
1. What problem are we solving?When a screen-reader user (TalkBack / VoiceOver) navigates forward by tapping a row, then navigates back, focus should land on the row they tapped so the screen reader can announce it again. Today it doesn't; focus collapses to the screen root / 2. What's the simplest naive solution, and why doesn't it work?Naive solution A "just call Naive solution B "use the right navigation library;
There is no library configuration that obviates the rescue. The contract has to live in the app. 3. What's the proposed solution?A small, two-step rescue:
Web is the sibling that already shipped (PR #87852); this PR extends the same idea to native + closes a handful of mobile-web edges discovered along the way. 4. Can the PR be broken up?I'd recommend keeping it as one PR. The diff looks large (82 files, +4486) but 76% of net additions are tests. Production code is 49 files / +945 net lines. We already achieved the core issue ~20th commit, but there were many edge cases to handle in review like flicking, slow navigation re-attach, form submit. We tried to covered every-possible cases. |
|
PR is ready for re-review. I tried to test all possible cases again after refactor. I didn't find any issues. All yours! LMK if there is from your side. Both Codex and Melvin doesn't have any concerns to address. Thanks so much for staying and supporting on this journey! |
|
If it helps simplify the solution, we can solve this for native first and web later. what do you think? @TaduJR |
Web is already implemented in this PR #87852 previously if you recalled.
|
|
ah cool |
| * Subscribers `.then()` it to catch the boot-race — the platform listener only fires on toggles, never on the initial state. | ||
| * `refresh()` invalidates the memo and re-warms; used on AppState resume to recover from toggles that fire while no JS listener was active. | ||
| */ | ||
| function makeWarmCache<T>( |
There was a problem hiding this comment.
Why do we need this function?
Should we use onyx instead of Set as our data structure
There was a problem hiding this comment.
AccessibilityInfo.isScreenReaderEnabled() is async, but capture/restore in NavigationFocusReturn.native.ts runs synchronously inside onPress and the navigation reducer can't await. The platform screenReaderChanged listener only fires on toggles, never the initial state. makeWarmCache bridges this: eager warm at module load, sync accessor, AppState refresh, generation token to discard superseded fetches. Same shape applies to reduce-motion, so it's one helper instead of two.
Set and Onyx: The Set<() => void> holds useSyncExternalStore subscriber callbacks non-serializable React-renderer functions. Onyx can't store functions; Onyx itself uses Map/Set for its own subscriber registry. Persisting the cached boolean across launches would also be wrong OS-level SR state can change while the app is killed, and Onyx reads are async (defeats the sync cache). Established convention in this codebase: AgentZeroReasoningStore, NetworkState, RevealedCardSecretsStore all use the same Set + useSyncExternalStore pattern.
There was a problem hiding this comment.
Onyx can't store functions
Why are we storing functions? Can we store the params to call those functions instead?
I thought we are storing the ID of a view, and focusing on it as per the simplified solution posted above
There was a problem hiding this comment.
Two stores being conflated here. The Set in Accessibility/index.ts (screenReaderSubscribers) is NOT the view registry it's the useSyncExternalStore subscriber callback registry for the useScreenReaderStatus / useScreenReaderState hooks. When a component calls those hooks, React itself passes an opaque internal callback into our subscribe function; we keep it in the Set so we can notify React to re-read getSnapshot on AppState resume + warm-cache refresh. The callback takes zero arguments Set<() => void> there are no params to factor out the function IS React's notification protocol. Reimplementing it with IDs would mean rewriting useSyncExternalStore itself imo.
There was a problem hiding this comment.
I see
A small, two-step rescue:
- Capture. When a screen-reader user presses a row, we record {ref, identifier} against the screen they're navigating away from. The identifier is the row's existing id/nativeID/testID no new prop, no behavior change for sighted users.
- Restore. When the user navigates back, we look up that capture and fire AccessibilityInfo.sendAccessibilityEvent(view, 'focus') once the navigation transition settles.
How does that fit in this?
There was a problem hiding this comment.
That's the design already implemented in this PR
Should I continue explaining on how?
There was a problem hiding this comment.
yes please, im trying to understand what the code does, and why are we doing things in the current way
…Reader-Global-Focus-is-lost-when-returning-to-the-previous-screen
…Reader-Global-Focus-is-lost-when-returning-to-the-previous-screen # Conflicts: # src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
Explanation of Change
When a screen-reader user navigates back, focus now returns to the row they tapped to go forward (WCAG 2.4.3). Also fixes WCAG 2.4.7 (Focus Visible) on touch initial-focus and modal close.
Approach. On press, we record the element + its identifier against the screen the user is leaving. On back navigation, after the transition settles, we re-fire accessibility focus on that element. If
react-native-screensdetached the original view, a per-screen registry resolves the live re-mounted one by identifier. Bounded retry budget covers slow re-attach. Sighted users pay nothing the pipeline short-circuits when the screen reader is known-off.InteractionManagerontoTransitionTracker. Also fixes a latent ~1s back-nav stall.goBackskips restore so a re-focused row can't hijack the destination form's Save button.Fixed Issues
$ #77411
PROPOSAL:
Tests
1. Going back returns focus to the item you opened
2. Same behavior inside Workspace settings
3. Opening a screen announces it
Offline tests
Same as tests
QA Steps
// TODO: These must be filled out, or the issue title must include "[No QA]."
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android-Native-1.mp4
Android: mWeb Chrome
Android-mWeb-2.mp4
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Mac-Chrome.mp4