Modal Popover: focus restoration is dropped when the restore frame beats the inert cleanup
Feature Request / Bug Report
A modal Popover can close without restoring focus: FocusScope restores from a
requestAnimationFrame scheduled in a layout-effect cleanup, while usePopover takes the
inert off the rest of the page in a passive-effect cleanup. React runs layout-effect
cleanups during the commit and flushes passive-effect cleanups afterwards, so when the animation
frame arrives before that flush, restoreFocusToElement calls focus() on a trigger that is
still inert. Per the HTML focusing steps — "If new focus target is a focusable area and its DOM
anchor is inert, then return"
(https://html.spec.whatwg.org/multipage/interaction.html#focusing-steps) — the call does nothing
and reports nothing. Nothing retries, so focus stays on <body>.
It fails when the frame is fast, not when it is slow: on a loaded machine the frame lands 0.5–1.5 ms after the unmount instead of the 5–7 ms that lets the passive flush go first.
The code path (main @ 4693fcc844a341107e7dd26fe456edb1e269e44c)
packages/react-aria/src/overlays/usePopover.ts#L132-L141— a passiveuseEffectcallsariaHideOutside([...], {shouldUseInert: true})for a modal popover.packages/react-aria/src/overlays/ariaHideOutside.ts#L64-L66—setHiddensetselement.inert = trueon every node outside the popover, the trigger included; only that effect's cleanup (#L274-L286) clears it.packages/react-aria/src/focus/FocusScope.tsx#L760-L800—useRestoreFocus'suseLayoutEffectcleanup schedules therequestAnimationFramethat callsrestoreFocusToElement.
Nothing orders the two, and focusElement does not check that the focus landed.
Expected Behavior
Closing a modal popover returns focus to the trigger.
Current Behavior
document.activeElement is <body>, and stays there. Instrumented output from a failing run,
recorded by patching HTMLElement.prototype.focus to log its stack and, when the call did not
move document.activeElement, the element's connectivity, computed style and every ancestor
carrying inert, aria-hidden, hidden, display:none or visibility:hidden:
22.4 raf#4 fired after 7.9ms
22.4 raf#5 fired after 7.9ms
22.4 raf#8 fired after 0.5ms
22.6 focus() on button#react-aria-_r_1t_ "Delete" :: … | focusElement | restoreFocusToElement
22.6 !! focus did not land. active=body hasFocus=true connected=true tabindex=0
disabled=false display=inline-flex visibility=visible pointerEvents=auto
blockers=[button#react-aria-_r_1t_ "Delete"{inert}] rootNode=document
1040.5 end active=bodyrestoreFocusToElement picks the right element; the browser drops the call because the element
is still inert.
Possible Solution
Anything that orders the two, or that notices the drop:
- clear the
inertin a layout-effect cleanup, so it is gone before any restore frame runs; or - have
restoreFocusToElementcheckownerDocument.activeElementafterfocusElementand retry once the inert is cleared.
Context
In a design-system suite of ~1,070 browser stories run in Chromium, this failed 11 times in 36
runs (~31%) with test retries disabled — every failure a modal popover closing. It is masked by a
retry, which is why it reads as flake rather than as a defect. We currently repair it in our own
overlay host by listening for the react-aria-focus-scope-restore event, and re-focusing its
target one frame after close if document.activeElement is <body>.
Code Sample
<DialogTrigger>
<Button>Delete</Button>
<Popover>
<Dialog>
<Button slot="close">Cancel</Button>
</Dialog>
</Popover>
</DialogTrigger>Open it and press Escape. Under main-thread load the restore frame beats React's passive flush
and focus is left on <body>. What the restore does in that interleaving, on any browser:
trigger.inert = true;
trigger.focus();
document.activeElement === document.body; // true — the focusing steps return earlyYour Environment
| Software | Version(s) |
|---|---|
| react-aria | 3.51.0; 3.52.1 has the same race — usePopover.mjs and ariaHideOutside.mjs are byte-identical between the two, and FocusScope.mjs differs only by a null guard |
| react-aria-components | 1.20.0 |
| React | 19.2.7 |
| Browser | Chromium (Playwright 1.61.1) |
| Operating System | macOS 15 (Darwin 25.6.0) |
Source: adobe/react-spectrum