Skip to content

fix: Prevent stray focus ring in Safari when returning to a tab after a pointer interaction - #10513

Merged
yihuiliao merged 1 commit into
adobe:mainfrom
daveycodez:fix-focus-visible-window-refocus
Aug 27, 2026
Merged

fix: Prevent stray focus ring in Safari when returning to a tab after a pointer interaction#10513
yihuiliao merged 1 commit into
adobe:mainfrom
daveycodez:fix-focus-visible-window-refocus

Conversation

@daveycodez

Copy link
Copy Markdown
Contributor

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.

isFocusVisible is documented as "whether the element is keyboard focused", so a pointer or touch interaction should never produce one.

Root cause

handleFocusEvent treats 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). handleWindowBlur guards against that misfiring on window refocus by setting hasBlurredWindowRecently, 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 hasBlurredWindowRecently on 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-visible that useFocusRing derives from it, after clicking the button and switching tabs:

7   click BUTTON                 | modality=pointer  | data-focus-visible=false
10  blur target=BUTTON           | modality=pointer  | data-focus-visible=false
11  blur target=WINDOW           | modality=pointer  | data-focus-visible=false
12  visibilitychange -> hidden   | modality=pointer  | data-focus-visible=false
13  focus target=WINDOW          | modality=pointer  | data-focus-visible=false
14  focus target=BUTTON          | modality=pointer  | data-focus-visible=false   <- guard consumed here
15  focus target=WINDOW          | modality=pointer  | data-focus-visible=false
17  focus target=BUTTON          | modality=virtual  | data-focus-visible=YES     <- ring appears
18  visibilitychange -> visible  | modality=virtual  | data-focus-visible=YES

Chrome fires the pair once, which is why it isn't affected. (Chrome also fires visibilitychange -> visible before 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 -> visible arrives after all the focus events in Safari, and the document already reports visibilityState: 'visible' and hasFocus(): true by 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 hasBlurredWindowRecently whenever 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, matching handleWindowBlur, 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 handleFocusEvent so ignoreFocusEvent is checked first and the window and document cases are separate. No behavior change beyond the re-arm.

Alternatives I ruled out

  • Keeping the guard set until the next real user event. Fixes this, but suppresses virtual detection for a genuine screen reader or iOS next/previous focus that happens after returning to the tab.
  • Treating visibilitychange as a blur. Doesn't help — the guard is still consumed by the first pair regardless of what sets it.
  • A timeout or microtask to clear the guard. The duplicate pair arrives in the same tick, so there is no reliable window to clear it in.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

Notes on the checklist:

  • Storybook: no story added. The bug needs an OS-level tab or app switch to reproduce, which a story can't express; the existing Button/Link stories are what I reproduced against.
  • Documentation: no doc change needed — this restores the documented behavior of isFocusVisible / [data-focus-visible] rather than changing it.
  • AI assistance: this was AI-assisted. The instrumentation, the browser/simulator runs, and the reasoning about alternatives were done as described above, and I've reviewed and can explain every line.

📝 Test Instructions:

Automated:

  • yarn jest packages/react-aria/test/interactions/useFocusVisible.test.js — two new tests added alongside the existing toggleBrowserTabs ones, driven by a toggleBrowserTabsSafari helper that replays the captured Safari sequence (window/element focus pair twice, visibilitychange last).
  • The negative test fails on main and 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.
  • One wrinkle worth flagging: fireEvent produces untrusted events, and handleFocusEvent ignores those, so the existing toggleBrowserTabs helper doesn't actually reach the modality logic. The new helper routes element focus through jsdom's own focus() (via the unpatched copy stashed in hasSetupGlobalListeners) 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.
  • Full yarn test and yarn test:ssr run: 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:

  1. Open a page with a React Aria Button or Link in Safari on macOS.
  2. Click the button with the mouse.
  3. Switch to another tab, then switch back.
  4. On main a 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:

  • Mouse — Safari 26.5 / macOS 26.5, verified with a build from this branch.
  • Touch — iOS Safari 18.4 (iPhone 16 Pro simulator), backgrounding and returning to the app.
  • Keyboard — tabbed to the button in Safari, switched tabs and returned; the focus ring correctly persists, confirming the fix doesn't over-suppress.
  • Chrome 151, to confirm the existing behavior is unchanged — it fires the focus pair once, modality stays 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 handleWindowBlur already makes.

🧢 Your Project:

Personal project (HeroUI-based PWA).

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.

@snowystinger snowystinger left a comment

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.

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

@daveycodez

Copy link
Copy Markdown
Contributor Author

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!

@snowystinger

Copy link
Copy Markdown
Member

closing and reopening to try to get the CLA to take hold

@LFDanLu LFDanLu left a comment

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.

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.

@yihuiliao yihuiliao changed the title fix: Keep modality on window refocus so Safari doesn't show a focus ring after switching tabs fix: Prevent stray focus ring in Safari when returning to a tab after a pointer interaction Aug 27, 2026
@yihuiliao
yihuiliao added this pull request to the merge queue Aug 27, 2026
Merged via the queue into adobe:main with commit 11cfbcc Aug 27, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RAC] <Link> gets focus-outline attributes on window blur.

4 participants