Skip to content

fix(core): restore focus when a focus trap deactivates#3732

Open
bhamodi wants to merge 1 commit into
facebook:mainfrom
bhamodi:a11y/focustrap-restore-focus
Open

fix(core): restore focus when a focus trap deactivates#3732
bhamodi wants to merge 1 commit into
facebook:mainfrom
bhamodi:a11y/focustrap-restore-focus

Conversation

@bhamodi

@bhamodi bhamodi commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

useFocusTrap trapped focus but never restored it. The hook tracked focus inside the container (lastFocusRef, useFocusTrap.ts:207) purely for Tab-wrap logic, and its effect cleanups only removed listeners (useFocusTrap.ts:261-263, 339-344) -- it never captured document.activeElement on activation nor returned focus to it on deactivation.

The one caller of the hook is usePopover (usePopover.tsx:335 -- the only useFocusTrap() call site in the package; Dialog merely imports the helper utilities hasActiveFocusTrapEscape / isImeKeyEvent and does its own capture/restore at Dialog.tsx:376/405). Astryx opens layers imperatively via showPopover() (useLayer.tsx:333), so the browser's declarative popovertarget auto-restore never applies. The result: closing a Popover via Escape or light dismiss dropped keyboard focus to <body>. usePopover even declared a triggerElementRef (usePopover.tsx:319, assigned at 357) intended for this, but it was never read -- dead code standing in for a missing restore.

This fixes it in the primitive so every trap consumer benefits, via a single effect keyed on isActive:

  • On activation it snapshots document.activeElement as the restore target.
  • On deactivation / unmount (effect cleanup) it restores focus to that element, but only when focus would otherwise be lost.

Guard design (why it can't steal focus from consumers that self-restore)

The cleanup restores focus only when document.activeElement is null, document.body, document.documentElement, or still inside the (possibly already-unmounted) trap container. If focus has moved to some other element outside the trap, the restore is a no-op. This is safe because useLayer.hide() fires the consumer's onHide synchronously (useLayer.tsx:357) before React re-renders and runs the trap cleanup -- so a consumer that refocuses its trigger in onHide (e.g. DropdownMenu.tsx:220) has already moved focus outside the trap by the time cleanup runs, and the primitive stands down. The restore is also guarded against a captured element that was removed from the DOM (isConnected + focusable check), so it never throws.

Changes

  • hooks/useFocusTrap.ts: add a capture-on-activate / restore-on-deactivate effect with the guard above; update the file header and hook JSDoc to document the restore.
  • hooks/useFocusTrap.doc.mjs: document the restore behavior and add a best-practice note (rely on built-in restoration; add your own onHide only to send focus elsewhere).
  • hooks/useFocusTrap.test.tsx: 4 restore tests (below).
  • Popover/Popover.test.tsx: 2 integration tests (Escape + light dismiss return focus to the trigger).

Consumer trace

useFocusTrap() is called in exactly one place -- usePopover -- so the entire blast radius flows through Popover. Across the 16 usePopover consumers in the package the guard produces three behaviors, all correct:

  • Self-restoring dialogs (DropdownMenu, Selector, BaseTypeahead, DateInput, DateTimeInput, DateRangeInput, MultiSelector, PowerSearch, TopNavMegaMenu): their onHide moves focus to the trigger first -> primitive no-ops.
  • role: "none" popups (comboboxes / listboxes / mention menu -- Selector, Chat/useTriggerMenu, etc.): the trigger keeps DOM focus the whole time, so on close activeElement is already outside the trap -> primitive no-ops.
  • Dialogs with no self-restore (generic Popover, TabMenu, SideNavItem/SideNavHeading, TopNavHeading/TopNavMenu): previously dropped focus to <body>; now focus returns to the trigger -- this is the fix.

ContextMenu and CommandPalette use useLayer directly (not usePopover) and are unaffected. No opt-out flag was needed -- no consumer test broke, so the default-on behavior with the guard is sufficient.

Test plan

  • node_modules/.bin/vitest run --root . packages/core/src/hooks/useFocusTrap.test.tsx -- 10 pass. Four new tests under focus restoration:
    • restores focus to the previously-focused element when deactivated
    • does not steal focus when it was moved elsewhere outside the trap
    • does not crash or restore when the captured element was removed
    • restores focus when the trap unmounts while active
    • The two positive-restore tests (deactivate, unmount) were confirmed failing before the fix; the two guard tests correctly pass with or without it (they assert the primitive does not act).
  • node_modules/.bin/vitest run --root . packages/core/src/Popover/Popover.test.tsx -- 21 pass (2 new: Escape and light-dismiss both return focus to the trigger).
  • Full core suite -- node_modules/.bin/vitest run --root . packages/core/src -- 3851 pass across 182 files. No consumer test broke.
  • node_modules/.bin/tsc --project packages/core/tsconfig.json --noEmit -- clean.
  • node_modules/.bin/eslint on changed files -- clean.

Notes

Found during a broader a11y audit of core components; scoped to one fix per PR. No existing test encoded the focus-drop-to-<body> behavior, so nothing had to be weakened. The pre-existing unused triggerElementRef in usePopover is left untouched to keep this change focused on the primitive; it is now redundant and can be removed in a separate cleanup. Real light dismiss is not simulable in jsdom, so the light-dismiss test drives the same code path the browser uses -- the toggle (newState: "closed") event that useLayer listens for.

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

@bhamodi is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 9, 2026
@bhamodi bhamodi force-pushed the a11y/focustrap-restore-focus branch from c30ba23 to a7b661a Compare July 9, 2026 07:55
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.

1 participant