Skip to content

fix(Popover): cancel the auto-focus frame when the popover closes first - #8276

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f3-popover-stale-autofocus
Oct 24, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f3-popover-stale-autofocus

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

User impact

When a Popover, or anything built on usePopover such as a collapsed SideNav submenu, is dismissed within a frame of opening, focus jumps into the hidden popover's content. A quick Escape after a click or Enter is enough. Keyboard and screen-reader users are left on a control they can't see. The same race makes SideNav's "returns focus to the trigger when the collapsed submenu closes" test flaky on loaded runners, as seen on #8179: it took 1112 ms and ended with focus on the submenu's "General" link.

Problem and solution fit

  • Why is focus lost? usePopover focuses its content in a requestAnimationFrame after it opens and never cancels that frame.
  • Why does the close not fix it? The close runs first. useFocusTrap leaves focus on the trigger because focus never entered the trap. Then the frame runs and focuses the first control in content that is no longer shown.
  • Why does it matter? Focus sits inside a closed layer, where nothing announces it and Escape no longer applies.

The fix ties the frame to the open: closing or unmounting cancels it.

Expected behavior and authority

component:Popover (current):

  • Automatic focus moves focus into the content "for each accepted opening".
  • Dismissal returns focus to the trigger; Popover.test.tsx covers "returns focus to the trigger when closed via Escape".

An opening that has already closed has no content to focus.

Smallest restoration

  • usePopover's auto-focus effect returns () => cancelAnimationFrame(frame).
  • The per-open reset of the skip-auto-focus preference is unchanged.
  • No API, timing or visual change.

Evidence

  • Reproduction before the change: hold animation frames, open the collapsed SideNav submenu, press Escape, then run the held frame. Focus lands on the "General" link inside the closed submenu. The Popover equivalent lands on its content button.

  • Result after the change: in both cases the trigger keeps focus.

  • Representative unchanged path: opening still focuses the first content control in the next frame. Escape after that still returns focus to the trigger, now asserted without a timing wait.

  • Regression tests:

    • "does not move focus into a popover that closed before its first frame" (Popover);
    • "keeps focus on the trigger when the submenu closes before its first frame" (SideNav).

    Both fail with the fix reverted.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm vitest run --project ui on Popover, SideNav, and every directory with a usePopover consumer: Breadcrumbs, Chat, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, DropdownMenu, hooks, PowerSearch, Selector, TabList, TopNav, Typeahead, lab Schedule. 115 files, 3183 tests passed.
  • With usePopover.tsx reverted, the two new tests fail and the reworked SideNav focus-return test still passes. That test is now deterministic: it flushes the open frame, asserts focus is on "General", presses Escape, and asserts the trigger has focus immediately, with no waitFor.
  • pnpm -F @astryxdesign/core typecheck, eslint and prettier on the touched files, check:changesets, and pre-commit check:repo.

usePopover focuses its content in the frame after it opens, and never
cancelled that frame. A popover closed within it (Escape right after
opening) had already left focus on the trigger, since focus never
entered the trap; then the frame ran and focused the first control in
the closed popover's content. On a loaded runner jsdom's frame timer
runs late enough for SideNav's "returns focus to the trigger when the
collapsed submenu closes" to land there and fail.

The effect now cancels the frame on close or unmount. A Popover test
and a SideNav test hold the frame until after Escape and expect the
trigger to keep focus; the SideNav focus-return test drives its frame
explicitly instead of waiting on a timer.
@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 24, 2026 6:16pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 24, 2026, 6:37 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommended for approval: cancelling the auto-focus frame in the Effect's cleanup gives that frame the lifecycle owner the React runtime architecture requires, normal openings still focus their first content control, and both new regression tests fail without the fix and pass with it. One optional follow-up: in Chromium and WebKit the closed popover is display: none and already refused focus before this change, so the changeset's line about Escape now leaving focus on the trigger describes the jsdom race rather than something browser users will notice; consider rewording it to say the stale auto-focus frame is now cancelled.

[Automated review]

'@astryxdesign/core': patch
---

[fix] usePopover: a popover that closes or unmounts before its first frame no longer moves focus into its hidden content. Auto-focus waits a frame for the content to mount, and that frame is now cancelled with the open, so pressing Escape right after opening a Popover or a collapsed SideNav submenu leaves focus on the trigger.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In Chromium and WebKit the closed popover is display: none and already refused focus before this change: with the late frame run after Escape, focus stayed on the trigger on the base too. This line therefore describes the jsdom race rather than something browser users notice. Consider: "usePopover now cancels its auto-focus frame when the popover closes or unmounts before that frame runs, so a stale frame can no longer act on a closed popover."

@cixzhang
cixzhang merged commit a6740a7 into meta:main Oct 24, 2026
62 checks passed
@vjeux
vjeux deleted the uikit/f3-popover-stale-autofocus branch October 24, 2026 19:38

This branch was successfully deployed

1 active deployment
Preview — e653cbd4 Deployed Oct 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants