fix: Prevent stray focus ring in Safari when returning to a tab after a pointer interaction - #10513
Conversation
Safari fires the window/element focus pair twice when returning to a tab or app. The first element focus event clears hasBlurredWindowRecently, so the second one takes the 'focus without a preceding user event' path and switches to virtual modality, showing a focus ring on the element the user last clicked or tapped. Re-arm hasBlurredWindowRecently whenever the window itself is focused, since any focus event that follows is the browser restoring focus rather than a user initiated one.
There was a problem hiding this comment.
I can confirm in Chrome/Safari/FF desktop this appears to work correctly, will hopefully finish reviewing soon.
Thanks for the PR
Looks like you need to sign the CLA https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md#contributor-license-agreement make sure to use the same e-mail that was used to open the PR
Signed, thanks! |
|
closing and reopening to try to get the CLA to take hold |
LFDanLu
left a comment
There was a problem hiding this comment.
Tested in Chrome, FF, and Safari and verified the proper behavior again our RAC Button docs locally. Seems to still work well with virtual focus via Voiceover too.
Closes #7468 (and #9157, closed as a duplicate of it)
What I wanted and why
Clicking or tapping a button, link, accordion, etc., then leaving the tab or app and coming back, leaves a keyboard focus ring on the element you last touched. It happens in Safari on macOS and iOS but not in Chrome. It is especially noticeable in an installed iOS PWA, where switching back into the app is routine, so the app is regularly re-entered with a stray focus ring on whatever was last tapped.
isFocusVisibleis documented as "whether the element is keyboard focused", so a pointer or touch interaction should never produce one.Root cause
handleFocusEventtreats a focus event with no preceding keyboard or pointer event as virtual modality (this is how iOS form next/previous navigation and screen reader focus are detected).handleWindowBlurguards against that misfiring on window refocus by settinghasBlurredWindowRecently, because the browser restores focus to the previously focused element without any user event.That guard assumes exactly one focus event follows. Safari fires the window/element focus pair twice when returning to a tab or app. The first element focus event is correctly ignored but clears
hasBlurredWindowRecentlyon its way out, leaving the second one unguarded. It then takes the virtual branch and turns the focus ring on.I instrumented the real listeners in Safari 26.5 (macOS 26.5) and iOS Safari 18.4. Both show the same thing — this is the raw event sequence, with the modality and
data-focus-visiblethatuseFocusRingderives from it, after clicking the button and switching tabs:Chrome fires the pair once, which is why it isn't affected. (Chrome also fires
visibilitychange -> visiblebefore the focus events, the opposite of Safari — matching the note already in the existing test helper.) Lines 13–17 all arrive in the same tick (0ms apart), so this isn't something a timeout could distinguish.Worth noting for anyone who suspects visibility instead:
visibilitychange -> visiblearrives after all the focus events in Safari, and the document already reportsvisibilityState: 'visible'andhasFocus(): trueby line 13, so visibility state can't separate line 14 from line 17. The window blur does fire correctly — the bug is only that the guard it sets is single-use.The fix
Re-arm
hasBlurredWindowRecentlywhenever the window itself is focused, instead of relying solely on the blur to set it once. A window focus event always precedes focus restoration, so any focus event after it is restoration rather than user initiated — which is the same thing the blur was already standing in for.This keeps the guard scoped to the restoration burst. It is not held open indefinitely: the first element focus event still clears it, so a genuine virtual focus later on (iOS next/previous, screen reader) is still detected. I deliberately did not gate the re-arm on
isTrusted, matchinghandleWindowBlur, which doesn't either — window focus is only about tracking whether the window changed, not which modality the user used.I also reordered the guards in
handleFocusEventsoignoreFocusEventis checked first and the window and document cases are separate. No behavior change beyond the re-arm.Alternatives I ruled out
visibilitychangeas a blur. Doesn't help — the guard is still consumed by the first pair regardless of what sets it.✅ Pull Request Checklist:
Notes on the checklist:
isFocusVisible/[data-focus-visible]rather than changing it.📝 Test Instructions:
Automated:
yarn jest packages/react-aria/test/interactions/useFocusVisible.test.js— two new tests added alongside the existingtoggleBrowserTabsones, driven by atoggleBrowserTabsSafarihelper that replays the captured Safari sequence (window/element focus pair twice,visibilitychangelast).mainand passes with the fix. The positive one passes either way and is there to guard the opposite direction — that keyboard focus rings still survive a tab switch.fireEventproduces untrusted events, andhandleFocusEventignores those, so the existingtoggleBrowserTabshelper doesn't actually reach the modality logic. The new helper routes element focus through jsdom's ownfocus()(via the unpatched copy stashed inhasSetupGlobalListeners) so the events are trusted and the code path is genuinely exercised. Happy to extend that to the existing helpers in a separate PR if you want it.yarn testandyarn test:ssrrun: SSR is fully green, and Jest shows the same 114 pre-existing failures before and after this change (I diffed the failing test names — none are new; locally they look like a Node version artifact,Symbol.dispose is not defined).yarn lint/ formatting clean on the changed files.Manual — reproduces on
main, fixed here:maina focus ring appears on the button. With this change it does not.iOS (this is #7468's report): same steps, tapping the element, then backgrounding Safari and returning.
What I tested:
pointer, no ring, same before and after.Not tested: RTL, high contrast, zoom levels, screen readers, Firefox. This is a modality-tracking change with no visual or layout surface, so I focused on the modality paths, but the screen reader path is the one I'd most want a second pair of eyes on — the concern would be a virtual focus arriving immediately after window focus, which is the same tradeoff
handleWindowBluralready makes.🧢 Your Project:
Personal project (HeroUI-based PWA).