#4148·primitives

[FocusScope] Unmount `setTimeout` is never cleared and uses realm globals, so it can throw after test environment teardown

Author: jmhodgesCreated Sep 17, 2026Updated Sep 17, 2026

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 error

Vitest 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

See https://github.com/jmhodges/radix-ui-bugs/tree/0e4f2e85be30351fdb2725247d5babf166fd9a85/focus-scope-unmount-timer

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:

diff
         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 CustomEvent fixes Dialog, whose close handler prevents the default. A bare FocusScope still takes the hand-back path and throws ReferenceError: document is not defined.
  • container.ownerDocument.defaultView.CustomEvent does not work under Vitest. Vitest's jsdom environment redefines document.defaultView to return the global object, so after teardown it yields Node's CustomEvent again. 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() in onUnmountAutoFocus still skips the hand-back. The unmount event is still a realm Event (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.isConnected can'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:

typescript
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