Skip to content

fix: [Web] Focus restoration mechanism on back navigation (#76921)#79834

Open
mavrickdeveloper wants to merge 18 commits intoExpensify:mainfrom
mavrickdeveloper:fix/76921-focus-restoration-clean
Open

fix: [Web] Focus restoration mechanism on back navigation (#76921)#79834
mavrickdeveloper wants to merge 18 commits intoExpensify:mainfrom
mavrickdeveloper:fix/76921-focus-restoration-clean

Conversation

@mavrickdeveloper
Copy link
Contributor

@mavrickdeveloper mavrickdeveloper commented Jan 18, 2026

Explanation of Change

  • This PR fixes the Web-only issue where keyboard navigation focus was lost across 48 scenarios/pages after navigating back in the app, causing screen readers to announce incorrect elements.
    adherence to WCAG 2.4.3 - Focus Order (Level A) , WCAG 3.2.3 Consistent Navigation (AA) , WCAG 2.1.2 No Keyboard Trap (A) & , WCAG 2.1.1 Keyboard (A)

  • Keyboard focus order , should not conflict with pre-existing custom (mouse/touch) focus behavior

  • Notes to code reviewers: fix: [Web] Focus restoration mechanism on back navigation (#76921) #79834 (comment)

Root Cause:

Solution: Implement a custom NavigationFocusManager that:

Fixed Issues

$ #76921
PROPOSAL: #76921 (comment)

Prerequisite:

The user is logged in
Using Windows + Chrome, open expensify dev
Navigate using TAB only in the scenarios below
Navigate to the 'Back' button using TAB, press enter to activate
Observe the focus behavior , the correct behavior is the back button (in RHP for eg) should restore the keyboard focus to the triggering element

Tests

Primary Test (Issue #76921):

Note : Tests should only be executed using keyboard navigation (TAB) , (See attached video below for demonstration)

  1. Navigate to Settings > Preferences
  2. Click on "Language" menu item
  3. Press Escape or navigate back
  4. Verify focus returns to the "Language" menu item
  5. With screen reader enabled, verify the correct label is announced

Additional scope : Other pages to test on:

  1. On Settings - About - Keyboard Shortcuts

    • How to test: Navigate to Settings > About > Use TAB to focus "Keyboard shortcuts" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Keyboard shortcuts" menu item with visible focus indicator
  2. On Settings - Save the world - I know a teacher

    • How to test: Navigate to Settings > Save the world > Use TAB to focus "I know a teacher" option > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "I know a teacher" menu item with visible focus indicator
  3. On Settings - Save the world - I am a teacher

    • How to test: Navigate to Settings > Save the world > Use TAB to focus "I am a teacher" option > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "I am a teacher" menu item with visible focus indicator
  4. On Settings - Save the world - Intro your school principal

    • How to test: Navigate to Settings > Save the world > Use TAB to focus "Intro your school principal" option > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Intro your school principal" menu item with visible focus indicator
  5. On Settings - Preferences - Language

    • How to test: Navigate to Settings > Preferences > Use TAB to focus "Language" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Language" menu item with visible focus indicator
  6. On Settings - Preferences - Priority mode

    • How to test: Navigate to Settings > Preferences > Use TAB to focus "Priority mode" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Priority mode" menu item with visible focus indicator
  7. On Settings - Security

    • How to test: Navigate to Settings > Use TAB to focus "Security" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Security" menu item with visible focus indicator
  8. On Settings - Security - Validate your account

    • How to test: N/A - Not a standalone menu item; part of Two-factor authentication flow
    • Expected behavior: N/A
  9. On Settings - Security - Close account

    • How to test: Navigate to Settings > Security > Use TAB to focus "Close account" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Close account" menu item with visible focus indicator
  10. On Settings - Security - Two-factor authentication

    • How to test: Navigate to Settings > Security > Use TAB to focus "Two-factor authentication" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Two-factor authentication" menu item with visible focus indicator
  11. On Settings - Profile - Display name

    • How to test: Navigate to Settings > Profile > Use TAB to focus display name row > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the display name menu item with visible focus indicator
  12. On Settings - Profile - Contact methods

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Contact methods" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Contact methods" menu item with visible focus indicator
  13. On Settings - Profile - Pronouns

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Pronouns" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Pronouns" menu item with visible focus indicator
  14. On Settings - Profile - Share Code

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Share Code" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Share Code" menu item with visible focus indicator
  15. On Settings - Profile - Legal Name

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Legal Name" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Legal Name" menu item with visible focus indicator
  16. On Settings - Profile - DOB

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Date of birth" menu item > Press Enter to activate > Tap ESC keyboard button > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Date of birth" menu item with visible focus indicator
  17. On Settings - Profile - Phone number

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Phone number" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Phone number" menu item with visible focus indicator
  18. On Settings - Profile - Address

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Address" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Address" menu item with visible focus indicator
  19. On Settings - Profile - Country

    • How to test: Navigate to Settings > Profile > Address > Use TAB to focus "Country" field > Press Enter to activate > Select a country > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Country" field or Address menu item with visible focus indicator
  20. On Settings - Profile - Timezone

    • How to test: Navigate to Settings > Profile > Use TAB to focus "Timezone" menu item > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Timezone" menu item with visible focus indicator
  21. On Workspaces - Duplicate Workspaces

    • How to test: Navigate to Workspaces > Use TAB to focus workspace row > TAB to three dots button > Press Enter > Use TAB to focus "Duplicate workspace" > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "More" button with visible focus indicator
  22. On Workspaces - Delete Workspace

    • How to test: Navigate to Workspaces > Use TAB to focus workspace row > Press Enter > Use TAB to focus More (...) button > Press Enter > Use TAB to focus "Delete workspace" > Press Enter > Use TAB to focus Cancel button > Press Enter
    • Expected behavior: Focus returns to the "More" button with visible focus indicator
  23. On Workspaces - Overview - Workspace Name

    • How to test: Navigate to Workspaces > Use TAB to focus workspace > Press Enter > Use TAB to focus "Workspace name" menu item > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Workspace name" menu item with visible focus indicator
  24. On Workspaces - Overview - Expensify Policy

    • How to test: Navigate to Workspaces > Use TAB to focus workspace > Press Enter > Use TAB to focus workspace avatar/policy settings > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the policy settings menu item with visible focus indicator
  25. On Workspace - Categories - Add Category

    • How to test: Navigate to Workspace > Categories > Use TAB to focus "Add category" button > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Categories" menu item in workspace sidebar with visible focus indicator
  26. On Workspace - Categories - Settings

    • How to test: Navigate to Workspace > Categories > Use TAB to focus a category row > Press Enter to activate > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Categories" menu item in workspace sidebar with visible focus indicator
  27. On Workspace - Workflows - Edit Approval Workflow

    • How to test: Navigate to Workspace > Workflows > Use TAB to focus "Add approvals" or edit approval > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Workflows" menu item in workspace sidebar with visible focus indicator
  28. On Workspace - Workflows - Expenses From

    • How to test: Navigate to Workspace > Workflows > Use TAB to focus "Delay submissions" > Press Enter > Use TAB to focus frequency option > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Workflows" menu item in workspace sidebar with visible focus indicator
  29. On Workspace - Workflows - Approver

    • How to test: Navigate to Workspace > Workflows > Use TAB to focus approver settings > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Workflows" menu item in workspace sidebar with visible focus indicator
  30. On Workspace - Rules - Cash Expense Default

    • How to test: Navigate to Workspace > Rules > Use TAB to focus cash expense setting > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Rules" menu item in workspace sidebar with visible focus indicator
  31. On Workspaces - Distance Rates - Rate Details

    • How to test: Navigate to Workspace > Distance rates > Use TAB to focus a rate row > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Distance rates" menu item in workspace sidebar with visible focus indicator
  32. On Workspaces - Expensify Card - Add bank account

    • How to test: Navigate to Workspace > Expensify Card > Use TAB to focus "Add bank account" or setup flow > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Expensify Card" menu item in workspace sidebar with visible focus indicator
  33. On Workspaces - Expensify Card - Bank info

    • How to test: Navigate to Workspace > Expensify Card > Use TAB to focus bank info settings > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Expensify Card" menu item in workspace sidebar with visible focus indicator
  34. On Workspaces - Expensify Card - Confirm currency and country

    • How to test: Navigate to Workspace > Expensify Card > Card setup flow > Use TAB to focus Confirm currency/country step > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step or "Expensify Card" menu item with visible focus indicator
  35. On Workspace - Company Card - Add Cards

    • How to test: Navigate to Workspace > Company cards > Use TAB to focus "Add cards" or card feed option > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Company cards" menu item in workspace sidebar with visible focus indicator
  36. On Workspace - Create Workspace - Confirm Workspace

    • How to test: Use TAB to focus "+" button > Press Enter to create new workspace > Reach confirmation step > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in workspace creation flow with visible focus indicator
  37. On Workspace - Create Workspace - Invite new members

    • How to test: During workspace creation > Invite members step > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in workspace creation flow with visible focus indicator
  38. On Workspace - Create Workspace - Default Currency

    • How to test: During workspace creation > Currency selection step > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in workspace creation flow with visible focus indicator
  39. On Create Report - Restricted

    • How to test: Navigate to create report flow with restricted permissions > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the element that initiated the report creation with visible focus indicator
  40. On Create Report - Add payment card

    • How to test: Create report flow > Add payment card step > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in report creation flow with visible focus indicator
  41. On Create Report - Change payment currency

    • How to test: Create report flow > Use TAB to focus Change currency option > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in report creation flow with visible focus indicator
  42. On Track Distance

    • How to test: Use TAB to focus "+" button > Press Enter > Use TAB to focus Track distance > Press Enter > Fill route details > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the route input field or previous navigation element with visible focus indicator
  43. On Track Distance - Choose Recipient

    • How to test: Track distance flow > Choose recipient step > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step in track distance flow with visible focus indicator
  44. On Send Invoice

    • How to test: Use TAB to focus "+" button > Press Enter > Use TAB to focus Send invoice > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to previous step , "+" button
  45. On Wallet - Add bank account

    • How to test: Navigate to Wallet > Use TAB to focus "Add bank account" > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the "Add bank account" option or Wallet menu with visible focus indicator
  46. On Create Expense flow

    • How to test: Use TAB to focus "+" button > Press Enter > Use TAB to focus Create expense > Press Enter > Fill expense details > Use TAB to focus Back button at any step > Press Enter
    • Expected behavior: Focus returns to previous step in expense creation flow with visible focus indicator
  47. On Paid Expense details flow

    • How to test: Navigate to a paid expense > Use TAB to focus expense details > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the expense row in the report with visible focus indicator
  48. On Reports flow

    • How to test: Navigate to Reports > Use TAB to focus a report > Press Enter > View report details > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the report row in the reports list with visible focus indicator
  49. On Chat flow

    • How to test: Navigate to a chat room > Use TAB to focus room title (for eg: #admins) in RHP > Press Enter > Use TAB to focus Back button > Press Enter
    • Expected behavior: Focus returns to the element that opened the RHP (member row or settings button) with visible focus indicator

Regression Test (Issue #46109):

  1. Open the app URL in a new browser tab
  2. Verify NO blue frame appears around any element
  3. Verify focus is NOT incorrectly restored
  • Verify that no errors appear in the JS console

Offline tests

N/A - Focus restoration is a client-side UI behavior that doesn't depend on network state.

QA Steps

Same as tests

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

2026-01-13.17-13-53.mp4

@mavrickdeveloper
Copy link
Contributor Author

mavrickdeveloper commented Jan 18, 2026

Note for code-reviewers

This PR fixes Keyboard navigation focus loss during back navigation on Web. Three components work together:

  • NavigationFocusManager: Captures focus at T+0ms (before navigation), tags with route key
  • FocusTrapForScreen: Restores focus via initialFocus callback
  • NavigationRoot: Cleans up focus data when routes are removed from navigation state

useNavigationState approach didn't work for Focus Capture, as there are a fundamental constraint. You cannot reliably capture focus state using reactive patterns (hooks) when the action of navigation itself causes focus to move.

The timing issue

When a user clicks a button that triggers navigation, the events unfold in a specific order: at T+0ms the user clicks and activeElement is the button; at T+1ms the click handler calls Navigation.navigate(); sometime later React processes the state change and useNavigationState fires; and by then, the screen transition has begun and activeElement has already moved to body.

By the time useNavigationState fires, focus has already moved. The hook tells you navigation happened, not what was focused before it happened.

Reactive vs Proactive

useNavigationState is reactive,it responds to navigation that already occurred, by which point focus is already lost. What we need is proactive capture,grabbing the element at the moment of user intent, before anything else happens.

My Solution

Capture-phase DOM events (pointerdown, keydown with {capture: true}) run at T+0ms, before any click handlers execute, before navigation triggers, before focus moves. This is the only mechanism that fires early enough to capture the correct element.

React hooks are designed to respond to state changes. But we need to capture state before the change occurs. This requires stepping outside React's reactive model entirely.


Changes

src/libs/NavigationFocusManager.ts

Issue: By the time FocusTrap callbacks fire, focus has already moved to document.body.

Logic: Capture-phase listeners grab the element BEFORE navigation logic runs. Each capture is tagged with the current route key for state-based validation.

Key features:

  • pointerdown/keydown capture-phase listeners
  • State-based menuitem skip (preserves anchor button for dropdowns)
  • Route-tagged captures (forRoute) for deterministic validation
  • cleanupRemovedRoutes() removes stale focus data when routes leave navigation state
  • Keyboard interaction tracking for modal auto-focus decisions

src/libs/Navigation/NavigationRoot.tsx

Issue: Focus data for removed routes was never cleaned up, causing potential memory leaks and stale restoration.

Logic: Calls NavigationFocusManager.cleanupRemovedRoutes(state) in handleStateChange. Route existence in navigation state is the source of truth,no timestamps needed.


src/components/ButtonWithDropdownMenu/index.tsx

Issue: After clicking dropdown menu item, the menu item is removed from DOM, breaking focus restoration.

Logic: Added keyboard interaction tracking via NavigationFocusManager.wasRecentKeyboardInteraction(). Focuses anchor button after popover closes. NavigationFocusManager then captures this valid element instead of the removed menu item.


src/components/ThreeDotsMenu/index.tsx

Issue: Menu opened via keyboard (Enter/Space) needed different focus handling than mouse clicks.

Logic: Captures keyboard interaction state BEFORE menu opens using NavigationFocusManager.wasRecentKeyboardInteraction(). Clears flag after capture to prevent stale state.


src/components/PopoverMenu.tsx

Issue: Focus restoration after popover close needed to respect keyboard vs mouse interaction.

Logic: Integrates with NavigationFocusManager for keyboard interaction tracking. Ensures proper focus restoration based on how the popover was opened.


src/components/ConfirmModal.tsx

Issue: Modal focus handling needed to distinguish keyboard vs pointer interactions.

Logic: Added keyboard interaction detection for proper auto-focus behavior. When opened via keyboard, modal content receives focus appropriately.


src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx

Issue: Composer focus behavior needed integration with NavigationFocusManager.

Logic: Added focus restoration integration to work with the navigation focus system.


tests/unit/libs/NavigationFocusManagerTest.tsx

Coverage: Comprehensive test suite with 82 tests covering:

  • State-based validation (route must be registered before capture is valid)
  • cleanupRemovedRoutes() removes focus data for removed routes
  • Keyboard interaction tracking
  • Element identifier matching for screen remounts
  • Menuitem protection for dropdown menus

Design Decisions

  • State-based validation over time-based: Route key tagging (forRoute) validates captures deterministically. No arbitrary timeouts that fail on slow devices. Route existence in navigation state is the source of truth.

  • cleanupRemovedRoutes() integration: Called from NavigationRoot on every state change. Automatically removes focus data when routes are removed (back navigation, tab switches). Prevents memory leaks and stale restoration.

  • Keyboard interaction tracking: wasRecentKeyboardInteraction() allows components to detect Enter/Space activation. Enables proper auto-focus behavior in modals opened via keyboard.

  • State-based menuitem skip: Checks element semantics (!element.closest('[role="menuitem"]')) rather than time expiry. Never expires, works for slow users.

  • Capture-phase listeners: {capture: true} ensures we run at T+0ms, before any React handlers or navigation logic. This is the only reliable way to capture focus before it moves.

@mavrickdeveloper mavrickdeveloper changed the title fix: ensure focus restoration on back navigation (#76921) fix: Focus restoration mechanism on back navigation (#76921) Jan 18, 2026
@mavrickdeveloper mavrickdeveloper changed the title fix: Focus restoration mechanism on back navigation (#76921) fix: [Web] Focus restoration mechanism on back navigation (#76921) Jan 18, 2026
@mavrickdeveloper mavrickdeveloper marked this pull request as ready for review January 21, 2026 18:53
@mavrickdeveloper mavrickdeveloper requested review from a team as code owners January 21, 2026 18:53
@melvin-bot
Copy link

melvin-bot bot commented Jan 21, 2026

@ikevin127 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]

@melvin-bot melvin-bot bot removed request for a team January 21, 2026 18:54
// Use startsWith for robustness against minor content changes
const candidateText = (candidate.textContent ?? '').slice(0, 100).trim();
if (identifier.textContentPreview && candidateText.startsWith(identifier.textContentPreview.slice(0, 20))) {
score += 30;
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-2 (docs)

The magic number 20 in slice(0, 20) is used in the element matching logic without clear documentation or being defined as a named constant. This reduces code maintainability.

Suggested fix:
Define a constant at the top of the file:

const TEXT_CONTENT_PREFIX_LENGTH = 20; // Chars to match for fuzzy text comparison

Then use it in the code:

if (identifier.textContentPreview && candidateText.startsWith(identifier.textContentPreview.slice(0, TEXT_CONTENT_PREFIX_LENGTH))) {
    score += 30;
}
```\n\n---\n\nPlease rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will address

Copy link
Contributor

Choose a reason for hiding this comment

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

@mavrickdeveloper ✅ This is a valid review comment, all magic numbers should be extracted to CONST and named accordingly for what their use is.

  • all score related ones (5, 10, 15, 30, 40, 50)
  • both slice related (20, 100)

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50e2a4d6b7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 220 to 226
const computeInitialFocus = (() => {
const platform = getPlatform();

// Skip for mouse/touch opens or non-web platforms
if (!wasOpenedViaKeyboardRef.current || platform !== CONST.PLATFORM.WEB) {
return false;
}

Choose a reason for hiding this comment

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

P2 Badge Evaluate keyboard-opened modals after flag is captured

On the first render when isVisible flips to true, wasOpenedViaKeyboardRef.current is still undefined because it’s only set in the useLayoutEffect below. That means computeInitialFocus is computed as false and never re-evaluated (no state update), so a ConfirmModal opened via keyboard won’t auto-focus its first button on web. This is a regression for keyboard users because the focus trap never gets the intended initial target. Consider deferring the keyboard check to the initialFocus function itself or forcing a re-render after capturing the flag.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

addressed : 7a6ea74

trjExpensify
trjExpensify previously approved these changes Jan 21, 2026
Copy link
Contributor

@trjExpensify trjExpensify left a comment

Choose a reason for hiding this comment

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

Accessibility project issue 👍

@ikevin127
Copy link
Contributor

ikevin127 commented Jan 23, 2026

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified tests pass on all platforms & I tested again on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Screen.Recording.2026-01-22.at.19.54.10.mov

@ikevin127
Copy link
Contributor

ikevin127 commented Jan 23, 2026

🟡 Medium: Verbose Logging in Production

Files:

  • src/components/ConfirmModal.tsx
  • src/components/PopoverMenu.tsx
  • src/libs/NavigationFocusManager.ts

The file contains extensive Log.info calls that will execute in production. Why it matters: These logs could:

  • Impact performance on every click/keypress
  • Expose element details to console in production
  • Violate Expensify's logging guidelines for non-error scenarios

⚠️ Additional Security Considerations

Element attributes exposed in logs - aria-labels might contain sensitive data.

@mavrickdeveloper Let's remove these if they are not used for debugging anymore, or if they are really needed for some reason they must be wrapped by an if (Environment.isDevelopment()) {} to ensure they will only show-up on local dev.

Comment on lines 226 to 255
const computeInitialFocus = (): HTMLElement | false => {
const platform = getPlatform();

// Check ref LAZILY - this runs when FocusTrap activates (after useLayoutEffect)
if (!wasOpenedViaKeyboardRef.current || platform !== CONST.PLATFORM.WEB) {
return false;
}

// CRITICAL: Scope query to this modal's container
// This prevents focusing buttons from OTHER open modals
// in nested scenarios (e.g., ThreeDotsMenu → PopoverMenu → ConfirmModal)
const container = modalContainerRef.current as unknown as HTMLElement;
if (!container) {
// Fallback: If container ref not set, use last dialog (legacy behavior)
Log.warn('[ConfirmModal] modalContainerRef is null, falling back to last dialog');
const dialogs = document.querySelectorAll('[role="dialog"]');
const lastDialog = dialogs[dialogs.length - 1];
const firstButton = lastDialog?.querySelector('button');
return firstButton instanceof HTMLElement ? firstButton : false;
}

const firstButton = container.querySelector('button');

Log.info('[ConfirmModal] initialFocus activated via keyboard', false, {
foundButton: !!firstButton,
buttonText: firstButton?.textContent?.slice(0, 30),
});

return firstButton instanceof HTMLElement ? firstButton : false;
};
Copy link
Contributor

@ikevin127 ikevin127 Jan 23, 2026

Choose a reason for hiding this comment

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

🟡 Medium: Complex Function in ConfirmModal

The computeInitialFocus function is 48 lines with multiple responsibilities. Why it matters:

  • Mixes platform detection, container lookup, fallback logic, and logging
  • Hard to unit test individual concerns
  • The as unknown as HTMLElement cast suggests type issues (⚠️ also type casting is forbidden)

Suggested Refactor:

/**
 * Find the first focusable button in this modal's container.
 * Scopes the query to avoid focusing buttons from other open modals.
 */
const findFirstFocusableButton = (): HTMLElement | false => {
    // Don't rely on ref casting - query the DOM directly
    // The modalContainerRef's testID allows us to find it reliably
    const container = document.querySelector('[data-testid="confirm-modal-container"]');
    
    if (!container) {
        // Fallback: Find last dialog (this modal)
        const dialogs = document.querySelectorAll('[role="dialog"]');
        const lastDialog = dialogs[dialogs.length - 1];
        
        if (!lastDialog) {
            return false;
        }
        
        const button = lastDialog.querySelector('button');
        return button instanceof HTMLElement ? button : false;
    }
    
    const button = container.querySelector('button');
    return button instanceof HTMLElement ? button : false;
};

// src/libs/focusUtils/computeInitialFocus/index.web.ts
/**
 * Compute initialFocus for Modal's FocusTrap.
 * Returns the first button for keyboard opens, false otherwise.
 */
const computeInitialFocus = (): HTMLElement | false => {
    if (!wasOpenedViaKeyboardRef.current) {
        return false;
    }
    
    return findFirstFocusableButton();
};

// src/libs/focusUtils/computeInitialFocus/index.ts
/**
 * Compute initialFocus for Modal's FocusTrap on native.
 * Native platforms handle focus differently, so this is a no-op.
 * 
 * @returns false - Native platforms don't use HTML-based focus traps
 */
function computeInitialFocus(): false {
    return false;
}
export default computeInitialFocus;

✅ No platform checks - Extract platform dependent function to platform-specific files
✅ No type casting - Uses document.querySelector directly instead of trying to cast the React Native ref
✅ Relies on testID - The modalContainerRef has testID="confirm-modal-container" which we can query
✅ Type-safe fallback - Falls back to [role="dialog"] query which is already in the DOM
✅ instanceof checks - Uses proper type guards instead of casts

This works because:

  • On web, the View with testID="confirm-modal-container" renders as <div data-testid="confirm-modal-container">
  • We don't need to access the ref at all - just query the DOM directly
  • TypeScript is happy because we're using proper DOM APIs throughout

The ref was only needed for scoping, which we now achieve via the testID selector.


Caution

Before pushing the refactored code please ensure that the logic still works as the previous logic (using ref) to avoid regressions if pushing refactor without verification.

Comment on lines 124 to 127
if (wasFocused && !isNowFocused) {
// Screen is losing focus (forward navigation) - capture the focused element
NavigationFocusManager.captureForRoute(route.key);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Critical: Missing null check could cause runtime error

Why it matters: If route is undefined (edge case during rapid navigation), route.key access throws:

TypeError: Cannot read properties of undefined (reading 'key')
Suggested change
if (wasFocused && !isNowFocused) {
// Screen is losing focus (forward navigation) - capture the focused element
NavigationFocusManager.captureForRoute(route.key);
}
if (wasFocused && !isNowFocused && route?.key) {
// Screen is losing focus (forward navigation) - capture the focused element
NavigationFocusManager.captureForRoute(route.key);
}

Comment on lines 259 to 270
function initialize(): void {
if (isInitialized || typeof document === 'undefined') {
return;
}

// Capture phase runs BEFORE the event reaches target handlers
// This ensures we capture the focused element before any navigation logic
document.addEventListener('pointerdown', handleInteraction, {capture: true});
document.addEventListener('keydown', handleKeyDown, {capture: true});

isInitialized = true;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: Potential memory leak in event listeners

Why it matters: If initialize() is called multiple times (e.g., during hot reload), and destroy() isn't called in between, listeners accumulate.

Bug Scenario:

  • User navigates, triggers capture
  • Hot reload occurs
  • useEffect in App.tsx runs again
  • isInitialized is true (module state persists), but effect cleanup ran destroy()
  • Listeners are gone but not re-added

Fix:

Suggested change
function initialize(): void {
if (isInitialized || typeof document === 'undefined') {
return;
}
// Capture phase runs BEFORE the event reaches target handlers
// This ensures we capture the focused element before any navigation logic
document.addEventListener('pointerdown', handleInteraction, {capture: true});
document.addEventListener('keydown', handleKeyDown, {capture: true});
isInitialized = true;
}
function initialize(): void {
if (typeof document === 'undefined') {
return;
}
// Always clean up first to handle hot reload
if (isInitialized) {
destroy();
}
// Capture phase runs BEFORE the event reaches target handlers
// This ensures we capture the focused element before any navigation logic
document.addEventListener('pointerdown', handleInteraction, {capture: true});
document.addEventListener('keydown', handleKeyDown, {capture: true});
isInitialized = true;
}

Comment on lines 268 to 280
onModalHide={() => {
// Restore focus to captured anchor (web only)
// This improves accessibility by returning focus to the trigger element
if (getPlatform() === CONST.PLATFORM.WEB && capturedAnchorRef.current && document.body.contains(capturedAnchorRef.current)) {
capturedAnchorRef.current.focus();
Log.info('[ConfirmModal] Restored focus to captured anchor', false, {
anchorLabel: capturedAnchorRef.current.getAttribute('aria-label'),
});
}
// Reset the ref AFTER focus restoration (not in useLayoutEffect)
capturedAnchorRef.current = null;
onModalHide();
}}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: Platform-specific code without native fallbacks

Why it matters: Using document.body.contains() and .focus() directly assumes web platform - while gated by platform check, this pattern should use platform-specific files as per our code guidelines.

Suggested Pattern: To separate concerns, extract to a utility function:

// libs/focusUtils/index.ts (native)
export const restoreFocus = () => {}; // no-op
// libs/focusUtils/index.web.ts
export const restoreFocus = (element: HTMLElement | null) => {
    if (element && document.body.contains(element)) {
        element.focus();
    }
};

Comment on lines 1044 to 1047
const activeElement = document?.activeElement;
if (activeElement instanceof HTMLElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
blurActiveElement();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: Duplicated blur-only-inputs logic

Files:

  • src/components/MoneyRequestConfirmationList.tsx (lines 1042-1047)
  • src/pages/tasks/NewTaskPage.tsx (lines 68-73)

Both have identical logic:

const activeElement = document?.activeElement;
if (activeElement instanceof HTMLElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
    blurActiveElement();
}

Why it matters: Duplicated logic should be extracted to maintain consistency.

Suggested Fix:

  • additionally use existing CONST variables instead of in-code strings ✅
// src/libs/blurActiveElement/index.ts
export function blurActiveInputElement(): void {
    const activeElement = document?.activeElement;
    if (activeElement instanceof HTMLElement && 
        (activeElement.tagName === CONST.ELEMENT_NAME.INPUT || activeElement.tagName === CONST.ELEMENT_NAME.TEXTAREA)) {
        blurActiveElement();
    }
}

* Find an element in the current DOM that matches the stored identifier.
* Uses a scoring system to find the best match.
*/
function findMatchingElement(identifier: ElementIdentifier): HTMLElement | null {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: Impure function accessing global state

This is an impure (queries DOM). Why it matters: For testability, DOM queries should be injectable.

Suggested Pattern:

function findMatchingElement(
    identifier: ElementIdentifier,
    queryFn: (selector: string) => NodeListOf<HTMLElement> = (s) => document.querySelectorAll(s)
): HTMLElement | null {
    const candidates = queryFn(identifier.tagName);
    // ...
}


// Capture focus when screen loses focus (navigating away) and restore when returning
// useLayoutEffect runs synchronously, minimizing the timing window
useLayoutEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: useLayoutEffect with heavy DOM operations

Why it matters: useLayoutEffect blocks painting. Complex logic here can cause jank.

Suggested optimization:

useLayoutEffect(() => {
    // Keep synchronous checks minimal
    const wasFocused = prevIsFocused.current;
    prevIsFocused.current = isFocused;
    
    if (wasFocused && !isFocused) {
        NavigationFocusManager.captureForRoute(route.key);
    }
}, [isFocused, route.key]);

// Move restoration logic to useEffect (can be async)
useEffect(() => {
    if (!prevIsFocused.current && isFocused && NavigationFocusManager.hasStoredFocus(route.key)) {
        // Focus restoration logic here
    }
}, [isFocused, route.key, isActive]);

// CRITICAL: Scope query to this menu's container
// This prevents focusing menuitems from OTHER open modals
// in nested scenarios (e.g., ThreeDotsMenu → PopoverMenu → ConfirmModal)
const container = menuContainerRef.current as unknown as HTMLElement;
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium: Same forbidden type casting

See comment https://github.com/Expensify/App/pull/79834/files#r2719350670 for how to address the forbidden type casting issue here.

@ikevin127
Copy link
Contributor

ikevin127 commented Jan 23, 2026

🔴 Failing Manual Tests

  1. On Workspace - Categories - Add Category
  • ❌ focus sometimes returns back to the + Add category button, sometimes to the wrong workspace sidebar menu item
Video proof
cat-new.mov
  1. On Workspace - Categories - Settings
  • ❌ focus returns back to the More button
Video proof
cat-settings.mov
  1. On Workspace - Workflows - Edit Approval Workflow
  2. On Workspace - Workflows - Expenses From
  3. On Workspace - Workflows - Approver
  • ❌ focus returns back to the section itself and not to Workflows workspace sidebar menu item
Videos proof

27

wf-edit.mov

28

wf-sub.mov

29

wf-add.mov
  1. On Workspace - Rules - Cash Expense Default
  2. On Workspaces - Distance Rates - Rate Details
  3. On Workspaces - Expensify Card - Add bank account
  4. On Workspaces - Expensify Card - Bank info
  5. On Workspaces - Expensify Card - Confirm currency and country
  6. On Workspace - Company Card - Add Cards
  • ❌ focus returns back to the section / button itself and not to Workflows workspace sidebar menu item
Videos proof

30

rul.mov

31

dis.mov

☝️ It happened maybe once or twice during the entire manual testing that the focus actually returned back to the workspace sidebar menu item - @Expensify/design what's your take on this, should we actually stick to the tests on this "workspace sidebar" tests from tests 25-35 or should the focus actually go back to the element that opened the menu / RHP like my videos are showing ?


✅ All other tests are passing.

Tip

Regarding merging with focus that's not persistent (always works the same) I think we're fine with having multiple follow-up PRs since this keyboard focusing feature does not actually block users from using the app - are only accessibility related issues which don't require reverts as the regressions won't be deploy blockers because this initial PR does improve accessibility overall.


@mavrickdeveloper Will be awaiting for you to address all code review / testing issues before doing another pass before merge 👍 Take your time - goal here is to minimize regressions as much as possible while having clean code 🟢

@dubielzyk-expensify
Copy link
Contributor

It happened maybe once or twice during the entire manual testing that the focus actually returned back to the workspace sidebar menu item - @Expensify/design what's your take on this, should we actually stick to the tests on this "workspace sidebar" tests from tests 25-35 or should the focus actually go back to the element that opened the menu / RHP like my videos are showing ?

I think for focus we should always go back to the element that opened the RHP. So the video looks right to me.

If a user is in a flow going through elements like this

  • Element 1
  • Element 2
  • Element 3 (CLICKS)
    • Sub-element 1
    • Sub-element 2
    • Sub-element 3
  • GO BACK

Then I'd expect them to go back to element 3 not element 1 or something else. This leaves the user in the process they were in and can continue to open element 4, 5, so on.

Does that make sense?

@mavrickdeveloper
Copy link
Contributor Author

Appreciate the thorough review @ikevin127 , will address your code review comments.

But before diving into the failing tests (25-35), I believe what your videos show is actually the intended behavior of my PR & per the original issue. From #76921:

"focus should return to the element that triggered the navigation"

So for eg: test 26 (Categories - Settings), focus returning to the More button is correct ,that's the element that triggered the navigation, not the workspace sidebar menu item.

Also as @dubielzyk-expensify described #79834 (comment)

Thoughts ?

…x matches in element scoring

Swapped condition order so exact matches now correctly score higher than prefix-only
matches. Also replaced all magic numbers with named constants following the MATCH_RANK
pattern from filterArrayByMatch.ts.
@mavrickdeveloper mavrickdeveloper force-pushed the fix/76921-focus-restoration-clean branch from 97b4aea to 170a84c Compare February 7, 2026 23:15
Reason:
improves keyboard focus determinism and cleanup safety while preserving mouse/touch behavior by isolating platform-specific focus
helpers, deduplicating input blur logic, and closing a provenance-only cleanup gap.

- extract ConfirmModal focus restore logic into web/native helpers
- add shared blurActiveInputElement helper for input/textarea-only blur behavior
- scaffold NavigationFocusManager metadata/provenance for keyboard-safe routing
- fix cleanupRemovedRoutes to clear provenance-only route state
- expand focus tests across NavigationFocusManager, FocusTrap, ConfirmModal, and keyboard-intent integration
@ikevin127
Copy link
Contributor

ikevin127 commented Feb 9, 2026

@mavrickdeveloper Please consider the CONTRIBUTING: Submit your pull request for final review rule quoted below as I noticed you already force-pushed two times:

  1. Please never force push when a PR review has already started (because this messes with the PR review history)

Any update on the status / completion % of the PR ?

- PopoverMenu: compute initial focus from actionable button/menuitem candidates and skip disabled, inert, and non-focusable rows
- NavigationFocusManager: add shared listener registry with ownership checks to prevent stale/duplicate listeners across hot reload
- Add regression tests for popover keyboard selection/Enter behavior and NavigationFocusManager module-replacement scenarios
@mavrickdeveloper
Copy link
Contributor Author

@ikevin127 Thanks for the thorough feedback, really appreciate it , I’ve addressed the code review points raised so
far , I’m planning one additional commit to further improve determinism, plus few small cleanup commits to reduce the PR
footprint, especially around the new tests

@codecov
Copy link

codecov bot commented Feb 18, 2026

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/App.tsx 100.00% <100.00%> (ø)
src/components/ApprovalWorkflowSection.tsx 0.00% <ø> (ø)
src/components/ConfirmModal.tsx 100.00% <100.00%> (ø)
src/hooks/useAutoFocusInput.ts 85.00% <100.00%> (+0.78%) ⬆️
...c/hooks/useSyncFocus/useSyncFocusImplementation.ts 100.00% <100.00%> (ø)
src/libs/Navigation/NavigationRoot.tsx 77.17% <100.00%> (+0.25%) ⬆️
src/pages/tasks/NewTaskPage.tsx 0.00% <ø> (ø)
src/pages/workspace/WorkspaceNamePage.tsx 0.00% <ø> (ø)
.../components/ConfirmModal/focusRestore/index.web.ts 97.14% <97.14%> (ø)
src/components/MenuItem.tsx 88.31% <83.33%> (+0.16%) ⬆️
... and 11 more
... and 56 files with indirect coverage changes

@ikevin127
Copy link
Contributor

@mavrickdeveloper How are we looking here - what's left to do ?

@mavrickdeveloper
Copy link
Contributor Author

@mavrickdeveloper How are we looking here - what's left to do ?

re-requested review , please check

onModalHide={() => {
// Focus the anchor button after modal closes but before navigation triggers
// This ensures NavigationFocusManager can capture it for focus restoration on back navigation
(dropdownAnchor.current as unknown as HTMLElement)?.focus?.();
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Critical Issue: Forbidden Type Casting (as unknown as HTMLElement)

Caution

Type casting is forbidden per Expensify's STYLE.md. This PR introduces a double cast in ButtonWithDropdownMenu.

Per the STYLE.md Refs section, when you need DOM methods on a RN ref, declare the ref as a union type and use proper narrowing:

// ✅ FIX — union type ref + proper narrowing
const dropdownAnchor = useRef<View | HTMLDivElement>(null);

// In onModalHide:
onModalHide={() => {
    if (dropdownAnchor.current && 'focus' in dropdownAnchor.current) {
        (dropdownAnchor.current as HTMLDivElement).focus();
    }
}}

Bug if not addressed: The double cast silences TypeScript entirely. If dropdownAnchor.current is not an HTMLElement on native platforms, calling .focus?.() would fail silently or crash.

@@ -0,0 +1,18 @@
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Critical Issue: Missing types.ts for Platform-Specific Modules

Important

Per STYLE.md: "In modules with platform-specific implementations, create types.ts to define shared types."

Files affected:

  • src/components/ConfirmModal/focusRestore/index.ts + index.web.ts
  • src/libs/Accessibility/blurActiveInputElement/index.ts + index.native.ts

Both modules define InitialFocusParams separately in each file. They should share a common types.ts:

// src/components/ConfirmModal/focusRestore/types.ts
type InitialFocusParams = {
    isOpenedViaKeyboard: boolean;
    containerElementRef: unknown;
};

type FocusRestoreModule = {
    getInitialFocusTarget: (params: InitialFocusParams) => HTMLElement | false;
    restoreCapturedAnchorFocus: (capturedAnchorElement: HTMLElement | null) => void;
    shouldTryKeyboardInitialFocus: (isOpenedViaKeyboard: boolean) => boolean;
    isWebPlatform: (platform: string) => boolean;
};

export type {InitialFocusParams, FocusRestoreModule};

Bug if not addressed: Each platform file can drift independently, breaking the cross-platform contract.


type InteractionTrigger = 'enterOrSpace' | 'escape' | 'pointer' | 'unknown';

type RetrievalMode = 'keyboardSafe' | 'legacy';
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 High Severity Issue: Dead/Unused Exported Code in NavigationFocusManager

The following types and functions are defined and exported but never imported anywhere else in src/:

Symbol Lines Status
RetrievalMode 80 Dead type
getRetrievalModeForRoute 809–815 Exported, never called
getRouteFocusMetadata 817–819 Exported, never called

Warning

The methods are only used in 1 testing file which is also irrelevant since there's no point in testing methods that are not actually used in the app.

// ❌ Dead code — remove or mark as intended for future use
type RetrievalMode = 'keyboardSafe' | 'legacy';

function getRetrievalModeForRoute(routeKey: string): RetrievalMode { ... }
function getRouteFocusMetadata(routeKey: string): RouteFocusMetadata | null { ... }

Why this matters: Dead exported code increases bundle size and creates false impressions about the public API surface.

* This prevents attempting to focus elements that have been hidden or disabled
* since they were captured.
*/
function isElementFocusable(element: Element | null): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 NAB: Duplicated Focus Validation Logic

  • isElementFocusable() in FocusTrapForScreen/index.web.tsx
  • isFocusableActionablePopoverCandidate() in PopoverMenu.tsx

both check disabled, inert, aria-disabled, and dimensions. These should be extracted into a shared utility.

// ✅ Extract to src/libs/focusUtils.ts (or similar)
function isElementFocusable(element: Element | null): boolean { ... }
function isFocusableActionable(element: Element): element is HTMLElement { ... }

Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 High Severity Issue: NavigationFocusManager Is Web-Only but Imported Unconditionally

NavigationFocusManager.ts relies heavily on document, HTMLElement, and DOM APIs. It's imported in:

  • App.tsx (all platforms)
  • ComposerWithSuggestions.tsx (all platforms)
  • ButtonWithDropdownMenu/index.tsx (all platforms)
  • ThreeDotsMenu/index.tsx (all platforms)
  • ConfirmModal.tsx (all platforms)

While initialize() guards with typeof document === 'undefined', every call site still imports the module and calls methods like wasRecentKeyboardInteraction() on native platforms (returning false by default). This works but wastes bundle space.

Consider creating a platform-specific split:

src/libs/NavigationFocusManager/
  index.ts        ← no-op stubs (native)
  index.web.ts    ← full implementation
  types.ts        ← shared type contract

@@ -639,5 +722,6 @@ export default React.memo(
prevProps.withoutOverlay === nextProps.withoutOverlay &&
prevProps.shouldSetModalVisibility === nextProps.shouldSetModalVisibility,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium Severity Issue: Missing wasOpenedViaKeyboard in React.memo Comparison

The newly added wasOpenedViaKeyboard prop is not included in the custom React.memo comparison function at the bottom of the file.

Bug if not addressed: If wasOpenedViaKeyboard changes but all other compared props remain the same, the component won't re-render, causing stale initial focus behavior.

Suggested change
);
prevProps.wasOpenedViaKeyboard === nextProps.wasOpenedViaKeyboard,
);

Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 NAB: Test File Names Don't Follow Existing Convention

Several test files use mixed naming patterns:

  • ButtonWithDropdownMenuFocusCoverageTest.tsx (PascalCase + "Test" suffix) ✅
  • blurActiveInputElementTest.ts (camelCase + "Test" suffix) ❌

Unify to:

  • BlurActiveInputElementTest.ts

@ikevin127
Copy link
Contributor

ikevin127 commented Feb 26, 2026

@mavrickdeveloper Dropped a few comments that need addressed:

  • 🔴 / 🟠 / 🟡 should all be addressed
  • those with NAB are optional but nice to have

Let me know when you're done addressing them and I'll take another look 🙌

@mavrickdeveloper
Copy link
Contributor Author

@ikevin127 thanks for the fast review , will address

@mavrickdeveloper mavrickdeveloper force-pushed the fix/76921-focus-restoration-clean branch from 14feebe to 826d7ae Compare February 27, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants