[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:
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:
focusItem enqueues focus into the highlighted option;
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<Element, number>), 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).
[floating-ui-react]
enqueueFocusmodule-levelrafIdlets concurrent callers cancel each other — keyboard-open focus hop into a roving-focus listbox is racyEnvironment
@base-ui/react1.6.0 (vendoredfloating-ui-reactutils)@storybook/test-runner); observed against a production Storybook buildSummary
floating-ui-react/utils/enqueueFocuskeeps a single module-level rAF handle, and its first statement cancels whatever focus rAF is pending — regardless of which caller scheduled it:On keyboard-open of a roving-focus popup (measured on
Select, ArrowDown to open), two callers race:focusItemenqueues focus into the highlighted option;FloatingFocusManager's initial-focus microtask callsenqueueFocusa 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-highlightedis 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
waitForondata-highlightedcan 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 +
focusinlog +data-highlightedmutation timeline. Local repro ~44% (30/68 runs) with a 5 swaitForactive on every failing run. The ledger shows:data-highlightedset ~1 ms after ArrowDown (sync with React state);enqueueFocuscall from the focus manager's initial-focus path;Suggested direction
Scope the cancellation to the caller or the element instead of the module — e.g. key pending handles per element (
WeakMap<Element, number>), or rely solely on the returned cancel function (which already guardsrafId === currentRafId) and drop the unconditionalcancelAnimationFrame(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).