Skip to content

Modal Popover: focus restoration is dropped when the restore frame beats the inert cleanup #10610

Description

@alexandru-bereghici-to

🙋 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 @ 4693fcc)

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=body

restoreFocusToElement 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 inert in a layout-effect cleanup, so it is gone before any restore frame runs; or
  • have restoreFocusToElement check ownerDocument.activeElement after focusElement and 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 early

🌍 Your 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions