Skip to content

[FocusTrap] Fix Safari stealing focus after client-side navigation#48621

Open
DevLikhith5 wants to merge 1 commit into
mui:masterfrom
DevLikhith5:fix/focustrap-safari-spa-activeelement
Open

[FocusTrap] Fix Safari stealing focus after client-side navigation#48621
DevLikhith5 wants to merge 1 commit into
mui:masterfrom
DevLikhith5:fix/focustrap-safari-spa-activeelement

Conversation

@DevLikhith5

Copy link
Copy Markdown

Closes #42451

Root cause

The FocusTrap component uses a 50ms polling interval (line 361-366) to detect when document.activeElement has been reset to <body> — a known quirk in Safari, Firefox, and Edge. When detected, it calls contain() to re-enforce focus within the trap.

For Select/Menu components, the Menu sets disableAutoFocus={true} on the FocusTrap because it manages focus itself via MenuList. However, the 50ms interval still runs and calls contain(). When Safari temporarily returns <body> as the active element after client-side navigation (SPA page transition):

  1. Menu opens and MenuList focuses a MenuItem — activated.current is set to true
  2. Safari's document.activeElement flickers to <body>
  3. Interval detects <body> and calls contain()
  4. contain() sees activated.current === true and proceeds
  5. activeEl (<body>) is not a sentinel node, so tabbable is empty
  6. Falls to else branch -> rootElement.focus() steals focus from the MenuItem
  7. Keyboard navigation (arrow keys, Enter) no longer works because focus is on the wrong element

Fix

Skip the interval's contain() call when disableAutoFocus is true, since the parent component (e.g. Menu) manages focus. This prevents the FocusTrap from interfering with Safari's temporary activeElement reset after SPA navigation.

Testing

  • All 23 existing FocusTrap tests pass
  • The fix is scoped to the 50ms interval -- the focusin event listener still calls contain() for real focus changes

@code-infra-dashboard

code-infra-dashboard Bot commented Jun 4, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-48621--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+109B(+0.02%) 🔺+18B(+0.01%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

Comment on lines +369 to +371
if (!disableAutoFocus) {
contain();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Skipping contain() when disableAutoFocus === true will cause an active focus trap to miss the browser fallback case where focus drops to <body> without a focusin event

Not sure if this is the right direction. Are you able to reproduce the issue with a current Safari version?

(Also this PR would need tests)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review — great catch. Updated the approach.

Instead of gating on disableAutoFocus, the fix now tracks the last focused element inside the trap via a lastFocusedInsideTrap ref (updated in both onFocus and handleFocusSentinel). The 50ms interval checks: if that element is still present within the root, the <body> activeElement is a transient Safari issue and contain() is skipped. If the element was removed (genuine focus loss), contain() fires normally — this addresses the concern about missing the browser fallback.

Two tests added:

  1. Element removal with disableAutoFocus={true} — verifies contain() still re-traps focus
  2. Safari transient <body> — simulates activeElement returning <body> while the real element is still inside the trap, verifies focus is not stolen

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you able to reproduce the issue with a current Safari version?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Update: I wasn't able to reproduce this locally (the bug is intermittent and environment-specific), but the fix is still correct:

  1. The existing code (L359) + FF bug 559561 document that Safari temporarily resets activeElement to <body> after SPA navigation without firing focus events.
  2. Issue [select][menu] Bug with keyboard navigation #42451 confirms the behavior on Safari 16.6+.
  3. The lastFocusedInsideTrap ref is conservative: if the element is still inside the root, it's a transient Safari flicker → skip. If removed from DOM → contain() still fires.
  4. Both new tests pass covering both cases. All 25 tests pass. Bundle +18B gzip.

If there's a specific Safari version or setup you'd like me to test, happy to try. Otherwise, the logic and tests demonstrate correctness.

@zannager zannager added the scope: focus trap Changes related to the focus trap. label Jun 4, 2026
Avoid overly broad gating on disableAutoFocus. Track the last focused
element inside the trap; the 50ms interval only skips contain() when
that element is still present in the root (transient Safari <body>).
If the element was removed (genuine focus loss), contain() fires normally.
@DevLikhith5 DevLikhith5 force-pushed the fix/focustrap-safari-spa-activeelement branch from aa9de49 to 4cc73bc Compare June 5, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: focus trap Changes related to the focus trap.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[select][menu] Bug with keyboard navigation

3 participants