#4136·primitives

roving-focus: arrow key swallowed when it lands inside the setTimeout-deferred focusFirst() window (12-14/15 under CPU throttle)

Author: ViktorBjornCreated Sep 1, 2026Updated Sep 1, 2026

Bug report

An ArrowRight on a Tabs tablist can be silently swallowed — focus and data-state both stay put — when the keypress lands inside the macrotask window opened by roving-focus's deferred focusFirst().

Reproduction rate, under 6x CPU throttling (Chrome DevTools):

  • 14/15 on one component's tablist
  • 12/15 on a second, unrelated component's tablist

Both use the same Tabs/TabsList/TabsTrigger wrapper with no custom key handling, no document-level listeners and no remounts, which is what rules out anything application-side.

Mechanism

RovingFocusGroupItem.onKeyDown computes the next-candidate list synchronously, then defers the actual move:

javascript
// @radix-ui/react-roving-focus/dist/index.mjs:194
setTimeout(() => focusFirst(candidateNodes));

That deferred macrotask is the only async boundary in the whole arrow-key path. A keypress arriving inside it is lost.

What we excluded, so you don't have to

The most plausible internal explanation was collection staleness: @radix-ui/react-collection's CollectionItemSlot re-registers into itemMap from a bare useEffect with no dependency array — a passive effect that could leave the collection briefly incomplete right after a focus-driven re-render.

Patched it to useLayoutEffect (synchronous with commit) in an isolated checkout: no change, reproduced at the same rate. So the race is inside the setTimeout-deferred focusFirst/.focus() sequence itself, not in item-collection staleness.

We could not pin the exact line, and this is why

Every diagnostic we added made the race disappear — including a completely passive, do-nothing focus/keydown capture listener. That is consistent with a sub-millisecond window and means there is nothing further to instrument without perturbing it away. Reporting it as a bounded result rather than dressing it up as a complete root cause.

One inconsistency worth recording rather than smoothing over: an earlier run of ours reported 10/10 reproduction with an in-page .focus() + same-tick dispatchEvent(keydown) unthrottled, while a later run got 30/30 clean with that same technique unthrottled and only reproduced once CPU throttling was added. Likely explanation, unconfirmed: a same-tick dispatchEvent is faster than a real click-then-keypress (no event-loop/IPC round trip) and so misses the window more often on an idle machine. Both runs agree on the mechanism; they disagree only on how much ambient load is needed to hit it on a given machine — which is what a sub-millisecond race would produce.

Severity, stated honestly

Low in practice. A real keyboard user clicking and then pressing a key essentially never lands inside a sub-millisecond window. We hit this in automated browser tests and under heavy main-thread load, not in ordinary use. Filing it because there is no fix surface outside this library, and because the next person who sees a flaky arrow-key test will otherwise spend the same days we did concluding it is their code.

Repro recipe

  1. Render any Tabs with 3+ triggers.
  2. CPU-throttle the page 6x (DevTools Performance).
  3. In-page: trigger.focus(), then a zero-latency dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })).
  4. Assert document.activeElement and data-state="active" moved. Expect ~12-14 failures in 15.

No sandbox link — adding instrumentation to a hosted repro reliably makes it vanish, which is the finding itself.