FocusScope skips a Tab stop after an exiting sibling scope unmounts
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
### 🐛 Bug Report
When one overlay opens another before it finishes closing, unmounting the first overlay can break keyboard navigation in the second. One Tab skips a focusable element. On a fresh opening, Shift+Tab can move focus outside the second overlay even though its `FocusScope` has `contain` enabled.
The two scopes are siblings under an outer `FocusScope` in the React component tree. The second scope stays mounted, and focus is already inside it when the first scope unmounts.
### 🤔 Expected Behavior?
Removing the closing overlay should not change keyboard navigation in the overlay that remains open. Tab should move to the next focusable element, and Shift+Tab from the first element should wrap to the last. Focus should stay within the containing scope.
### 😯 Current Behavior
After the closing overlay unmounts, the first Tab skips the next button. Testing Shift+Tab instead on a fresh opening moves focus to a button outside the remaining overlay.
### 💁 Possible Solution
Change `FocusScope` cleanup so that it resets `activeScope` only when the scope being removed is itself active. If the active scope is a descendant that remains mounted, preserve it while removing its ancestor and reparenting it.
In the cleanup condition, this means replacing `scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)` with `scopeRef === activeScope`. The `isAncestorScope` helper remains in its other call sites.
The added forward and reverse Tab regression tests fail without this change and pass with it. The existing whole-subtree focus restoration test also passes unchanged.
### 🔦 Context
Selecting a menu item opens another popover. The menu briefly remains mounted while the new popover opens. Once the menu unmounts, the first Tab in the new popover skips a button.
### 🖥️ Steps to Reproduce
https://codesandbox.io/p/sandbox/xfgnvy
1. Open the standalone preview.
2. Focus **Open menu** and press Space. Wait for focus to reach **Choose date and time**.
3. Press Space again, then wait for the first overlay to fade out. Focus is now on **September**, the first enabled button in the second overlay.
4. Press Tab once. Expected: **2026**. Actual: **Next month**, skipping **2026**.
5. Reload and repeat steps 2–3. Press Shift+Tab instead. Expected: **Next month** (wrap to the last button). Actual: **Open menu**, outside the second overlay.
After the closing overlay unmounts, a single Tab skips a button in the overlay that remains open, even though its FocusScope still has contain enabled.
### Version
react-aria@3.52.1
### What browsers are you seeing the problem on?
Chrome
### If other, please specify.
_No response_
### What operating system are you using?
macOS 26.4.1, arm64
### 🧢 Your Company/Team
_No response_
### 🕷 Tracking Issue
N/A
Contributor guide
Research direction
Start with the FocusScope cleanup condition and the other call sites of isAncestorScope; then run the forward- and reverse-Tab regression tests mentioned in the issue. Done means preserving an active descendant when an ancestor unmounts, with both regression tests passing and the existing whole-subtree focus restoration test unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100