Skip to content

fix: restore focus when overlays begin exiting - #10955

Open
bhumikabiyani wants to merge 3 commits into
adobe:mainfrom
bhumikabiyani:fix/menu-focus-restore-10943
Open

bhumikabiyani wants to merge 3 commits into
adobe:mainfrom
bhumikabiyani:fix/menu-focus-restore-10943

Conversation

@bhumikabiyani

@bhumikabiyani bhumikabiyani commented Nov 11, 2026 •

Copy link
Copy Markdown

Closes #10943

Summary

Problem

When an overlay with an exit animation (such as a RAC Menu popover or Modal dialog) is closed using the Escape key, focus previously remained on the active element inside the closing overlay (e.g. a MenuItem) for the duration of the CSS exit animation (typically 200–300 ms).

If a user pressed Enter or another key during this interval, intending to reopen the menu or activate its trigger, the closing MenuItem's onAction callback could be triggered unexpectedly.

Root Cause

When an overlay begins its exit animation:

  1. Overlay disables focus containment via contain={(props.shouldContainFocus || contain) && !isExiting}.
  2. FocusScope only restores focus during its unmount cleanup.
  3. Since the popover or modal DOM remains mounted while the CSS exit animation runs, document.activeElement continues pointing to an element inside the closing overlay until it unmounts.

Solution & Architecture

  • Keep FocusScope public API untouched: No overlay-specific isExiting or isDisabled prop is added to the public FocusScopeProps API. The animation lifecycle remains encapsulated within Overlay (@react-aria/overlays), which already receives isExiting from Popover and Modal. To avoid duplicate scope tree traversal logic, canonical FocusScope helpers (isElementInChildScope and restoreFocusToElement) are exported as /** @private */.
  • Deferred restoration with queueMicrotask and cancellation: In Overlay.tsx, focus restoration is scheduled via queueMicrotask when isExiting transitions to true. This ensures synchronous browser event dispatch and user interaction events on the closing turn (such as clicking an outside input, or complex touch drag resizers) settle cleanly before inspecting activeElement. An isCancelled flag in the effect cleanup prevents the queued microtask from stealing focus if the overlay is reopened or unmounted before the microtask executes.
  • Accurate scope check without DOM wrappers: The direct portal DOM hierarchy is preserved with zero wrapper elements. Focus containment inside the exiting overlay is verified by checking focusScopeTree.traverse() with node.nodeToRestore === nodeToRestore && isElementInChildScope(activeElement, node.scopeRef).
  • Preserve outside focus: If focus has moved outside the overlay (for example, clicking an external input), it is not moved back to the trigger.
  • useSelect.ts blur handling: When early restoration returns focus to the select trigger button while the listbox overlay is still mounted in its exit animation, useSelect's menuProps.onBlur checks nodeContains(ref.current, e.relatedTarget) to recognize that focus is still within the component, preventing premature blur callbacks.
  • SelectTester and MenuTester test-utility timing: In @react-aria/test-utils, SelectTester.close(), SelectTester.toggleOptionSelection(), MenuTester.close(), and MenuTester.toggleOptionSelection() now assert DOM unmounting inside waitFor(() => ...). Because focus is now restored on frame 0 during exit transitions rather than on unmount, test assertions relying on focus movement as a proxy for DOM unmounting now explicitly await DOM removal.
  • Pass isExiting only when closing: Popover and Modal pass isExiting={!state.isOpen && isExiting} so early restoration only runs when the overlay is logically closing.

Prior Attempts & Context

  • PR fix: Prevent Spectrum Picker from selecting new value after closing #8475 (commit db6c9347479 by @snowystinger): Added pointer-events: none to .spectrum-Popover.is-exiting to prevent mouse clicks on closing popovers from selecting items. This addressed pointer interactions but did not handle keyboard focus remaining on the active menu item during exit transitions.
  • Commit 77fbf106d41 (PR Table column resizing support for RAC #4785 by @devongovett): Added isExiting to OverlayProps and disabled focus containment during exit (contain && !isExiting), allowing focus to leave an exiting overlay. However, focus restoration was not triggered early, so focus remained inside the exiting overlay until unmount.
  • Evaluation of inert: Applying the HTML inert attribute to an exiting overlay was evaluated; however, per WHATWG HTML specification §7.4.3, making a focused element inert forces the browser to reset focus to document.body rather than restoring to the opening trigger, stranding keyboard focus.
  • Relationship to Menu: a Menu reopened during its exit animation does not take focus #10942: Issue Menu: a Menu reopened during its exit animation does not take focus #10942 tracks a separate problem where a menu reopened during its exit animation does not autofocus its first item. That issue concerns the useMenuTrigger / useSelectableCollection autofocus lifecycle and is intentionally kept separate. This PR verifies that completion of the previous menu's exit animation does not steal focus after the menu has been reopened.

Test Instructions (Manual Verification)

Scenario 1: RAC Menu with CSS Exit Animation

Original reproduction: StackBlitz – Menu focus remains in the closing Menu

  1. Open a Storybook story or sample app containing a RAC Menu with an exit animation. For example:
    .react-aria-Popover[data-exiting] {
      opacity: 0;
      transition: opacity 300ms ease;
    }
  2. Navigate to the menu trigger using Tab and press Enter to open the Menu.
  3. Verify that the first MenuItem receives focus.
  4. Press Escape to close the Menu.
  5. Expected: Focus immediately returns to the menu trigger button while the exit animation is still running.
  6. Press Enter again during the animation window (before the 300ms exit animation finishes).
  7. Expected: The Menu reopens rather than activating the closing MenuItem's onAction callback.

Scenario 2: Dialog / Modal with Exit Animation

  1. Open a Dialog or Modal using its trigger button.
  2. Verify that focus is on an interactive element inside the dialog.
  3. Press Escape to close it.
  4. Expected: Focus immediately returns to the opening trigger while the exit animation is running.
  5. Wait for the animation to finish.
  6. Expected: Focus remains on the opening trigger after the modal unmounts.

Scenario 3: Outside Interaction During Exit Animation

  1. Open a Menu or Popover next to an external <input> or button.
  2. Click directly on the external input while the overlay is open.
  3. Expected: Focus moves to and stays on the clicked input throughout the exit animation and after the overlay unmounts. It is NOT stolen back by the closing overlay trigger.

Automated Tests Added

  • packages/react-aria-components/test/Menu.test.tsx

    • restores focus to trigger immediately on Escape during exit animation without invoking onAction
    • unmounts normally after exit animation completes and preserves trigger focus
    • restores focus to parent submenu item when nested submenu is dismissed with Escape during exit animation
    • preserves focus on an external input when clicked to dismiss menu during exit animation
    • does not restore focus back to trigger after Menu is reopened during exit animation and animation completes
  • packages/react-aria-components/test/Dialog.test.js

    • restores focus to trigger immediately on Escape during exit animation

Verification Results

  • yarn format:check: Passed (6,545 files checked, 0 errors).
  • yarn lint: Passed with exit code 0 (2,847 files checked, 0 errors, 0 warnings).
  • yarn check-types: Passed with exit code 0.

Unit tests (514 passed across 10 suites):

Test suite Result
packages/react-aria/test/focus/FocusScope.test.js 61 passed
packages/react-aria-components/test/Menu.test.tsx 118 passed
packages/react-aria-components/test/Dialog.test.js 25 passed
packages/react-aria-components/test/Popover.test.js 15 passed
packages/@adobe/react-spectrum/test/menu/MenuTrigger.test.js 109 passed
packages/react-aria/test/overlays/useOverlay.test.js 20 passed
packages/@adobe/react-spectrum/test/overlays/Overlay.test.js 2 passed
packages/@adobe/react-spectrum/test/picker/Picker.test.js 67 passed
packages/@adobe/react-spectrum/test/picker/TempUtilTest.test.js 7 passed
packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx 90 passed

Release Impact

[✓] Fixes a bug or improves existing code without changing how apps use it.

PR Checklist

[✓] I followed the contributing guide: https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md

[✓] I tested this change locally and included the results in the Test Instructions section.

[✓] I understand and can explain every change in this PR. If I used AI assistance, I followed the AI contribution guidance: https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md#ai-assisted-contributions

Project

Adobe React Spectrum / Open Source Contribution

@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.

Test instructions are not to be which unit tests to run, they are how a human can verify the fix manually.

I don't see any mention of previous attempts at this work, were you unable to find any?

restoreFocus?: boolean,
contain?: boolean
contain?: boolean,
isExiting?: boolean

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.

isExiting makes no sense in FocusScope. Is it actually disabling the focus scope?

@bhumikabiyani bhumikabiyani Nov 11, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. I re-evaluated the layering and agreed that isExiting does not belong in @react-aria/focus or FocusScopeProps.

I have completely reverted all changes to FocusScope.tsx and FocusScope.test.js back to main. Early focus restoration is now cleanly encapsulated inside Overlay (@react-aria/overlays), which already receives isExiting from Popover and Modal. When isExiting transitions to true while mounted, Overlay synchronously restores focus to nodeToRestore if activeElement is still within the exiting overlay. When FocusScope unmounts after the exit animation ends, focus has already returned outside the scope, so FocusScope's unmount cleanup safely no-ops without any double restoration. FocusScopeProps remains completely untouched.

@bhumikabiyani

bhumikabiyani commented Nov 11, 2026 •

Copy link
Copy Markdown
Author

Thank you for the guidance! I have updated the PR description with:

  1. Human-readable manual testing steps covering:

  2. Prior attempts and investigation context, specifically:

In addition, I addressed your review comment on FocusScope: I reverted all changes to FocusScope.tsx and FocusScope.test.js back to main, leaving FocusScopeProps untouched, and encapsulated the early focus restoration directly within Overlay (@react-aria/overlays).

@bhumikabiyani bhumikabiyani changed the title fix(react-aria-components): restore focus when overlays begin exiting fix: restore focus when overlays begin exiting Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Menu: after Escape, focus stays in the closing Menu until its exit animation ends

2 participants