[FocusScope] Unmount `setTimeout` is never cleared and uses realm globals, so it can throw after test environment teardown
Bug report
Current Behavior
When a FocusScope unmounts, its effect cleanup schedules a zero-delay setTimeout that is never cancelled. The callback builds a CustomEvent from the global scope, dispatches it on the old container, then restores focus using document.
In a browser this is harmless. In a test runner that tears down a simulated DOM after each test file, the timer can fire after teardown. With Vitest + jsdom the globals have been restored to Node's by then, so new CustomEvent(...) builds Node's CustomEvent, and jsdom's dispatchEvent rejects it:
Unhandled Errors
Uncaught Exception
TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.
Object.exports.convert jsdom/lib/jsdom/living/generated/Event.js:22:9
HTMLDivElement.dispatchEvent jsdom/lib/jsdom/living/generated/EventTarget.js:236:24
Timeout._onTimeout @radix-ui/react-focus-scope/dist/index.mjs:92:21
listOnTimeout node:internal/timers
This error originated in "<some test file>" test file.
Test Files N passed (N)
Tests N passed (N)
Errors 1 errorVitest exits 1 even though every test passed.
Expected behavior
The unmount timer should not read realm globals (CustomEvent, document, HTMLInputElement) at fire time, since they may no longer belong to the container's document.
Reproducible example
Suggested solution
Capture what the timer needs synchronously in the effect cleanup, while the globals are still the right ones, and make focus() / isSelectableInput() realm-independent:
return () => {
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
+ // Capture the realm now. The timer below can fire after the environment that owned
+ // these globals is gone (e.g. a test runner tearing down jsdom), so it must not read
+ // `CustomEvent` or `document` from the global scope.
+ const ownerDocument = container.ownerDocument;
+ const UnmountEvent = CustomEvent;
+
// We hit a react bug (fixed in v17) with focusing in unmount.
// We need to delay the focus a little to get around it for now.
// See: https://github.com/facebook/react/issues/17894
setTimeout(() => {
- const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
+ const unmountEvent = new UnmountEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
container.dispatchEvent(unmountEvent);
if (!unmountEvent.defaultPrevented) {
- focus(previouslyFocusedElement ?? document.body, { select: true });
+ focus(previouslyFocusedElement ?? ownerDocument.body, { select: true });
}
@@
function isSelectableInput(element: any): element is FocusableTarget & { select: () => void } {
- return element instanceof HTMLInputElement && 'select' in element;
+ // realm-independent check (no global `HTMLInputElement`)
+ return element?.tagName === 'INPUT' && 'select' in element;
}
function focus(element?: FocusableTarget | null, { select = false } = {}) {
// only focus if that element is focusable
if (element && element.focus) {
- const previouslyFocusedElement = document.activeElement;
+ const ownerDocument = 'ownerDocument' in element ? element.ownerDocument : document;
+ const previouslyFocusedElement = ownerDocument.activeElement;Notes from testing this against 1.1.16 (vitest 4.1.6, jsdom 24.1.3, React 19.2, forced race):
- All four edits are needed. Capturing only
CustomEventfixes Dialog, whose close handler prevents the default. A bareFocusScopestill takes the hand-back path and throwsReferenceError: document is not defined. container.ownerDocument.defaultView.CustomEventdoes not work under Vitest. Vitest's jsdom environment redefinesdocument.defaultViewto return the global object, so after teardown it yields Node'sCustomEventagain. Capturing the constructor avoids that.- Behavior is unchanged in normal runs. Dialog still returns focus to the trigger. A bare scope still refocuses and selects the previously focused input.
preventDefault()inonUnmountAutoFocusstill skips the hand-back. The unmount event is still a realmEvent(non-bubbling, cancelable). - A container guard would be wrong. The container is already disconnected on a normal unmount, and the focus hand-back still has to happen then, so
container.isConnectedcan't be used as a guard.
Alternatives: per the source comment, the delay only exists for a React 16 bug (facebook/react#17894), and react ^16.8 is still a peer. If nothing else relies on it, the timer could be limited to React 16. A try/catch around the timer body would also silence it, but only hides the problem.
Happy to open a PR with the diff above.
Additional context
The same class of problem was fixed for Toast: #3703 ("Toast handleClose timeout not cleared on unmount"), fixed in #3794, released with the note "Cleared the close timer when unmounting Toast components to prevent memory leaks and errors in test environments."
Other projects have hit this exact FocusScope error and worked around it in test setup:
- superplanehq/superplane#7184
- thinkbig1979/capstan#311
There's also a workaround. In a shared Vitest setup file, you can capture the real setTimeout at load and add:
afterAll(() => new Promise((resolve) => realSetTimeout(resolve, 0)));Node fires same-delay timers in creation order, so each file waits for FocusScope's timer before teardown.
Your environment
| Software | Version |
|---|---|
| @radix-ui/react-focus-scope | 1.1.7 (same code in 1.1.16 and main) |
| React | 19.2 |
| Vitest | 4.1.6 |
| jsdom | 24.1.3 |
| @testing-library/react | 16.3 |
| Node | 22 |
Source: radix-ui/primitives