Skip to content

fix(site): flush deferred Radix focus-scope timer before jsdom teardown#26983

Draft
EhabY wants to merge 2 commits into
mainfrom
fix/site-radix-focus-scope-teardown-flake
Draft

fix(site): flush deferred Radix focus-scope timer before jsdom teardown#26983
EhabY wants to merge 2 commits into
mainfrom
fix/site-radix-focus-scope-teardown-flake

Conversation

@EhabY

@EhabY EhabY commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Problem

UserDropdownContent.test.tsx intermittently fails CI with an unhandled error during teardown:

TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.
 ❯ Timeout._onTimeout @radix-ui/react-focus-scope/dist/index.mjs:92:21

Radix's FocusScope defers its unmount event dispatch and focus restore with setTimeout(0) (a react#17894 workaround). The cleanup() call in the shared afterEach unmounts the tree and schedules that timer. For the last test in a file there is no next test to absorb it, so the timer can still be pending when vitest tears down the per-file jsdom environment. It then fires with Node's built-in CustomEvent instead of jsdom's, and jsdom rejects the dispatch, failing the run. Any test file whose last test unmounts a focus-trapped Radix component (Dialog, Popover, DropdownMenu) can hit this race.

Fix

Await one timer turn in the shared afterAll, after all tests and afterEach hooks but before environment teardown. Same-delay timers run in FIFO order, so this deterministically drains every 0ms timer scheduled by cleanup() while the jsdom realm is still alive. Cost is ~1ms per test file. A vi.isFakeTimers() guard prevents the flush from hanging if a test file leaves fake timers installed.

Closes coder/internal#1613

Investigation notes and validation

Failure chain

  1. Each test renders <DropdownMenu defaultOpen>, mounting a focus-trapped Radix FocusScope.
  2. Tests do not close the dropdown; unmount happens in the shared afterEach via RTL cleanup().
  3. FocusScope's effect cleanup schedules setTimeout(0) which constructs a CustomEvent and dispatches it on the (now detached) container.
  4. jsdom environments are per test file. Timers pending between tests fire inside the same living environment and are harmless; only a timer pending at file completion races env teardown.
  5. On teardown, vitest removes the jsdom globals, so CustomEvent resolves to Node's built-in (Node >= 19). Dispatching a Node-realm event on a jsdom element fails jsdom's brand check, producing the unhandled TypeError. Timing-dependent, hence flaky.

The commit blamed in the issue (adding a third test to the file) only shifted timing; the race predates it.

Why afterAll and not afterEach

The crash requires environment teardown, which happens exactly once per file, after all afterEach and afterAll hooks. Flushing once per file is positioned exactly in the gap and avoids paying ~1ms per test. Vitest's default sequence.hooks: "stack" runs setup-file afterAll hooks last, so test files' own afterAll hooks complete before the flush.

A microtask wait (await Promise.resolve()) would not work: setTimeout callbacks run in the timer phase after all microtasks.

Alternatives considered

  • Per-test flush in afterEach: same mechanism, stricter isolation, but pays the cost per test and the cross-test leakage it prevents is today's status quo and not the observed flake.
  • Fixing only this test file (explicit unmount() + flush): whack-a-mole; the race applies to every Radix-based test file.
  • Mocking @radix-ui/react-focus-scope: lowers test fidelity for focus behavior.
  • Upgrading radix/jsdom: no upstream change removes the deferred dispatch.

Validation: A/B flake reproduction, main vs. this fix

Methodology: 20 copies of UserDropdownContent.test.tsx per vitest invocation (20 jsdom env teardowns per run, i.e. 20 race opportunities), alternating main's and this branch's test/setup/msw.ts on every iteration to cancel out machine-load drift. Failure = vitest reports an unhandled error.

Round Conditions main this fix
1+2 idle 128-core machine, 60 invocations each (1200 teardowns) 3/60 failed 0/60
3 CPU-starved (taskset pinned to 2 cores), 12 invocations each (240 teardowns) 12/12 failed 0/12

Every main failure has the byte-identical signature from the CI flake (Event.js:22 brand check via react-focus-scope/dist/index.mjs:92:21 Timeout._onTimeout); under starvation several of the 20 files fail per invocation. The fix produced zero unhandled errors across all ~1440 teardowns, including the starved round, which is expected: the flush does not shrink the race window, it removes the pending timer entirely.

Also: full unit project green (198 files, 3026 tests, 25.96s), biome check and tsc -p . clean. The pre-existing close timed out after 1000ms message is unrelated (rolldown worker threads; teardownTimeout: 1000 is deliberate per the vite config comment).


This PR was generated by Coder Agents on behalf of @EhabY, investigating coder/internal#1613.

Radix's FocusScope defers its unmount event dispatch and focus restore
with setTimeout(0). The cleanup() in the shared afterEach schedules that
timer, and for the last test in a file it could still be pending when
vitest tears down the jsdom environment. The callback then constructs a
CustomEvent from Node's built-in constructor instead of jsdom's, and
jsdom rejects the dispatch with "parameter 1 is not of type 'Event'" as
an unhandled error, failing the run.

Await one timer turn in afterAll, while the jsdom environment is still
alive, to deterministically drain the 0ms timers scheduled by cleanup().
Timers with the same delay run in FIFO order, so this is not a race.

Fixes coder/internal#1613
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flake: UserDropdownContent.test.tsx

1 participant