Skip to content

A focus-scope timer outlives jsdom teardown, so the frontend gate fails with every test passing #701

Description

@JArmandoAnaya

A run of bash scripts/check.sh failed at the frontend stage while every frontend test passed. The ui-core suite reported Test Files 53 passed (53) and Tests 1149 passed (1149), and the run still exited 1, because vitest caught one unhandled error outside any test and counts that as a failed run. The only line in the summary that says so is Errors 1 error, sitting between two lines that both read as success.

The error, verbatim from the run:

⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯
Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.
 ❯ Object.exports.convert ../../node_modules/.pnpm/jsdom@30.0.1/node_modules/jsdom/lib/generated/idl/Event.js:22:9
 ❯ HTMLDivElement.dispatchEvent ../../node_modules/.pnpm/jsdom@30.0.1/node_modules/jsdom/lib/generated/idl/EventTarget.js:236:24
 ❯ Timeout._onTimeout ../../node_modules/.pnpm/@radix-ui+react-focus-scope@1.1.16_@types+react-dom@19.2.4_@types+react@19.2.18__@types_3b4e29b84c639c7da7ed6c275ef9f34b/node_modules/@radix-ui/react-focus-scope/dist/index.mjs:97:23
 ❯ listOnTimeout node:internal/timers:635:17
 ❯ processTimers node:internal/timers:571:7
This error originated in "src/annotator/addClass.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
 Test Files  53 passed (53)
      Tests  1149 passed (1149)
     Errors  1 error

What the stack says

The throw is not in our code and not in a test. @radix-ui/react-focus-scope schedules a timer when a focus scope unmounts, and that timer constructs and dispatches an event on the element it was watching. The frame is Timeout._onTimeout, so it fired from the node timer queue rather than from anything a test was awaiting. By the time it ran, the jsdom document the element belonged to had been torn down with the test that owned it, so the Event it built no longer satisfied jsdom's own brand check for the realm it was dispatched into, and the conversion in Event.js rejected it.

That makes the attribution line load-bearing in the way it warns about: addClass.test.ts is the file that happened to be running when the orphaned timer fired, not the file that scheduled it. Any suite mounting a dialog, popover or other focus-trapping surface is a candidate for the scheduling half, and the two halves need not be in the same file.

Reproduction

Intermittent, and so far only under load. The failing run was a full scripts/check.sh, which runs the python suite, both browser suites and the frontend suites on one machine. Two isolated re-runs of pnpm --filter @visionset/ui-core test came back 1149 passed (1149), Errors absent, exit 0: once on a feature branch touching only a Playwright spec under frontend/app, and once on unmodified main at da3304a in a throwaway worktree. So this is not a standing red on main and not a property of any one branch — it is a race whose losing side needs the machine to be busy.

A timer that outlives its document is also the shape that gets more likely on a smaller runner, not less, so the frontend job in CI is exposed to it.

Why this is worth fixing rather than re-running

A red exit code under a green test count is the failure mode that gets waved through. Someone reading the tail of a log sees two passing lines and a nonzero exit and concludes the harness is flaky; the next person re-runs it and it goes green, which confirms the wrong conclusion. Meanwhile vitest's own warning — that an unhandled error may have produced false positives — applies to every one of the 1149 results in that run, and nothing distinguishes a run where the orphaned timer merely threw from one where it also perturbed a test that then passed for the wrong reason.

Directions

Choosing between these is part of the work rather than settled here.

The scheduling site could be made to clean up after itself: find the suites that mount focus-trapping surfaces, and ensure each unmounts its tree before the test ends so the library's teardown timer runs while its document still exists. That fixes the cause and leaves the timer's behaviour alone, but it requires finding the real scheduler rather than the file the error was attributed to, and it stays fixed only as long as every future test remembers the same discipline.

Alternatively the environment could be made to not care: a global teardown that drains or clears pending timers between files, so nothing survives into a torn-down realm. That is one change in one place and covers suites nobody has written yet, at the cost of hiding a class of genuine leak that the current noise would have surfaced.

Either way the diagnosis is worth a test of its own, because the property that broke here — the frontend stage's exit code is trustworthy — is not currently asserted anywhere, and a green count beside a red exit is exactly what nobody reads carefully.

Metadata

Metadata

Assignees

No one assigned

    Labels

    frontendannotator / ui-core / app packagestoolingDeveloper tooling: scripts, checks, local workflow

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions