roving-focus: arrow key swallowed when it lands inside the setTimeout-deferred focusFirst() window (12-14/15 under CPU throttle)
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:
// @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
- Render any
Tabswith 3+ triggers. - CPU-throttle the page 6x (DevTools Performance).
- In-page:
trigger.focus(), then a zero-latencydispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })). - Assert
document.activeElementanddata-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.
Source: radix-ui/primitives