Skip to content

[floating-ui-react] enqueueFocus module-level rafId lets concurrent callers cancel each other — keyboard-open focus hop into a roving-focus listbox is racy #5268

Description

@johatan-bit

[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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    scope: all componentsWidespread work has an impact on almost all components.status: waiting for maintainerThese issues haven't been looked at yet by a maintainer.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions