[floating-ui-react] enqueueFocus module-level rafId lets concurrent callers cancel each other — keyboard-open focus hop into a roving-focus listbox is racy
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# [floating-ui-react] `enqueueFocus` module-level `rafId` lets concurrent callers cancel each other — keyboard-open focus hop into a roving-focus listbox is racy
## Environment
- `@base-ui/react` 1.6.0 (vendored `floating-ui-react` utils)
- React 18.3.1 / react-dom 18.3.1
- Chromium via Playwright (`@storybook/test-runner`); observed against a production Storybook build
## Summary
`floating-ui-react/utils/enqueueFocus` keeps a single **module-level** rAF handle, and its first statement cancels whatever focus rAF is pending — regardless of which caller scheduled it:
```js
let rafId = 0;
export function enqueueFocus(el, options = {}) {
// ...
cancelAnimationFrame(rafId); // cancels ANY pending caller's focus
// ...
const currentRafId = requestAnimationFrame(exec);
rafId = currentRafId;
// ...
}
```
On keyboard-open of a roving-focus popup (measured on `Select`, ArrowDown to open), two callers race:
1. `focusItem` enqueues focus into the highlighted option;
2. `FloatingFocusManager`'s initial-focus microtask calls `enqueueFocus` a moment later — **cancelling the option-focus rAF** — and re-issues focus through its own path.
Focus does settle on the option ~2–6 ms later, so real users are effectively unaffected. But there is a measurable window in which `data-highlighted` is already set (it lands synchronously with React state, ~1 ms) while DOM focus is still on the **trigger**.
## Impact
Automated keyboard tests that chain keystrokes flake with a misleading signature: a keystroke fired inside that window hits the still-focused trigger, so listbox navigation goes dead and the highlighted attribute never advances — **no finite `waitFor` on `data-highlighted` can help**, because the attribute is not the thing that's late; focus is. The signature misdirects investigation toward timeout or rAF-starvation theories (we instrumented both; neither was the cause).
Any Base UI roving-focus popup composed through these utils (Select, Menu, Combobox, …) shares the mechanism; we measured Select.
## Evidence
Instrumented Playwright harness against the built story iframe — rAF request/fire/cancel ledger + `focusin` log + `data-highlighted` mutation timeline. Local repro ~44% (30/68 runs) with a 5 s `waitFor` active on every failing run. The ledger shows:
- `data-highlighted` set ~1 ms after ArrowDown (sync with React state);
- the option-focus rAF cancelled by a later `enqueueFocus` call from the focus manager's initial-focus path;
- focus settling on the option 2–6 ms after open;
- zero rAF gaps through every failure window (no starvation).
## Suggested direction
Scope the cancellation to the caller or the element instead of the module — e.g. key pending handles per element (`WeakMap`), or rely solely on the returned cancel function (which already guards `rafId === currentRafId`) and drop the unconditional `cancelAnimationFrame(rafId)` preamble. Alternatively, the focus manager's initial focus could skip enqueueing when an item-focus is already pending for a node inside the floating element.
## Workaround
In tests: after keyboard-opening a roving-focus popup, assert that focus has entered the list (`expect(highlightedOption).toHaveFocus()`) before the next keystroke — asserting the focus contract instead of the visual attribute removed the flake entirely for us (0/40 post-fix, 3× full suite green).
Contributor guide
Research direction
Start at the vendored floating-ui-react/utils/enqueueFocus entry point and trace the Select keyboard-open path through focusItem and FloatingFocusManager. Reproduce the race with the instrumented Playwright harness, then verify that concurrent focus requests no longer cancel each other and that the highlighted option receives focus before the next keyboard input.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100