fix(test): clear leaked timers so one cannot fail an unrelated CI run - #1986
Conversation
A window.setTimeout that outlives its test file fires after happy-dom disposes that file's window; React's dispatchSetState then throws an uncaught ReferenceError: window is not defined. Vitest attributes it to whichever file was running, so a single leak fails the ENTIRE run with every test passing — landing on an innocent file under a step named "Enforce per-file coverage gate." Seen twice in CI, both on docs-only branches. Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount (useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the unmount lands, cancelAnimationFrame is a no-op and that callback then schedules a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is why it only ever appeared in CI. Track every timer and clear whatever is still outstanding after cleanup(). The ordering is load-bearing: legitimate unmount cleanups get their turn first, so only true leaks are dropped. Fake timers pass through untracked, which is correct — a fake timer cannot outlive the environment. Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that env="test" prevents this. It does not: env is read only by Transition.mjs at its render branch, while useTransition runs before that check (hooks cannot be conditional) and still schedules real timers. Measured — opening a <Modal> through renderWithMantine schedules three 200ms timers. Believing that guarantee is why this went unexamined. The regression test is mutation-verified: with the net disabled it fails, with it restored it passes. It deliberately does not assert that Mantine schedules no timers — it does, and that is normal; the contract is that none survive. Closes #1984 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Adds a test-wide timer safety net intended to prevent happy-dom teardown flakes.
Changes:
- Tracks and clears pending test timers after cleanup.
- Adds regression coverage for leaked timers and Mantine modals.
- Corrects timer-behavior documentation.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
clients/web/src/test/setup.ts |
Adds timer tracking and teardown cleanup. |
clients/web/src/test/leakedTimers.test.tsx |
Tests leaked-timer handling. |
AGENTS.md |
Corrects Mantine test guidance. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| // After cleanup(), anything still pending is a leak — drop it so it cannot | ||
| // fire past this file's environment teardown. | ||
| for (const id of pendingTimers) { | ||
| realClearTimeout(id); | ||
| } |
There was a problem hiding this comment.
This is the best catch of the review — the net still had the exact hole it was written to close, and I had described that hole in the commit message while leaving it open.
You are right about the mechanism: the afterEach is synchronous, so a queued frame callback cannot run until it returns, and it then registers a fresh setTimeout after the sweep has already drained. On the file's last test that timer survives teardown.
Fixed by tracking animation frames as well and cancelling them before sweeping timers. The ordering is the fix, not an incidental detail — JS is single-threaded and nothing between the two loops yields, so no frame callback can run in between:
for (const handle of pendingFrames) realCancelAnimationFrame(handle);
pendingFrames.clear();
// then, and only then:
for (const id of pendingTimers) realClearTimeout(id);
pendingTimers.clear();Covered deterministically as you asked, with a test that queues a frame which would register a timer, and a following test asserting neither ever ran. Mutation-checked so it cannot rot into a test that always passes:
| State | Result |
|---|---|
| frames not cancelled first | 1 failed | 6 passed |
| frames cancelled first | 7 passed |
Review found the net still had the exact hole it was written to close. The afterEach is synchronous, so a queued rAF callback cannot run until it returns — at which point it registers a fresh setTimeout AFTER the sweep has already drained. On a file's last test that timer survives environment teardown. That is the rAF race described in the previous commit message, left open by the fix for it. Track animation frames too, and cancel them BEFORE sweeping timers. Order is the fix: JS is single-threaded and nothing between the two loops yields, so no frame callback can run in between. Mutation-checked — without the cancel pass the new ordering test fails (1 failed | 6 passed); with it, 7 pass. Also from review: - renderWithMantine.tsx still documented the disproven env="test" behavior. It is the file contributors are told to use, so leaving it would have preserved the wrong belief exactly where it does most damage. That was the third copy of this claim in the repo; all three now corrected. - Dropped an unjustified `as unknown as` in the test helper, which broke the repo's own double-cast rule. Inference keeps the handle type clearTimeout accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 1 — all three valid; one was a hole in the fix itself (c4e8ed9)Mirroring at PR level, since the inline threads go outdated on push. 1. The sweep could still miss the race. The most important comment, and correct. The Now tracks animation frames too and cancels them before sweeping timers. The order is the fix: nothing between the two loops yields, so no frame callback can run in between. Mutation-checked:
2. 3. Unjustified Verification: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
AGENTS.md:638
- This review-relevant guidance now says the global
setup.tssafety net is what prevents the leak, but the same sentence still claims that a bareMantineProviderreintroduces it. The net is installed for every unit test regardless of render helper, so that rationale is now stale. The stale claim also remains in.github/copilot-instructions.md:95; AGENTS.md requires review guidance to be mirrored there in the same PR. Update both copies to preserve therenderWithMantinerule without attributing timer safety to the helper.
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it.
Review caught a second-order effect of the fix: making the leaked-timer net global invalidated the rationale of a neighbouring rule. "Do not hand-roll a bare MantineProvider — that reintroduces the leak class" stopped being true the moment the net moved into setup.ts, which covers every unit test regardless of how it renders. The rule itself is still right, so keep it and replace the justification: a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Both copies say explicitly that the old reason no longer holds, so nobody re-derives it. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for review-relevant guidance — it carried the identical stale claim. That makes four places this one belief had propagated to. A fix can create stale docs by making a previously-true statement false, which is easy to miss when only the changed lines get read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 2 — correct, and a second-order effect I had missed (b451949)Good catch, and a subtler class than the earlier ones: the fix invalidated a neighbouring rule's rationale. Once the net moved into The rule still stands, so it stays; only the justification changes:
Both copies say outright that the old reason no longer holds, so the next person does not reconstruct it from memory. You were also right that That is four places this one belief had reached. The through-line for this whole PR: a plausible mechanism claim gets copied outward and nobody re-derives it — and a fix can create stale docs by making a previously-true statement false, which is invisible if you only read the changed lines. Verification: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
clients/web/src/test/setup.ts:160
- Happy DOM returns a
NodeJS.Timeoutobject at runtime, so an explicitly cleared timer fails this numeric guard and remains inpendingTimers; teardown then treats it as leaked and clears it again. This also means the new “stops tracking” test passes without proving its claim becauseclearTimeoutis idempotent. Delete any defined handle regardless of its runtime representation.
window.clearTimeout = ((id?: number): void => {
if (typeof id === "number") {
pendingTimers.delete(id);
}
…ched Review was right, and I had assumed the handle type rather than checking it. Probed both under happy-dom: setTimeout -> typeof "object", constructor Timeout requestAnimationFrame -> typeof "object", constructor Immediate So `typeof id === "number"` in the clearTimeout wrapper never matched, and an explicitly-cleared timer was never untracked. Both `Set<number>` annotations and `const id: number` were fictions TypeScript could not catch, since the DOM lib DECLARES number while happy-dom RETURNS an object. The effect was benign — the teardown sweep re-clears them and clearTimeout is idempotent — but the sharper problem is that the test asserting this passed for the wrong reason. "clearTimeout doesn't throw" holds whether or not tracking works, so it proved nothing and hid the broken guard. Hold handles as `unknown`, route cancellation through two small helpers that carry the one justified cast each, and drop the numeric guards. The test now asserts the net's own bookkeeping through an exported pendingTimerCount(), and is mutation-checked: restoring the numeric guard fails it (1 failed | 6 passed). That is the second test in this PR that passed without proving its claim. Both had the same tell — the assertion would have held with the feature removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 3 — correct on a fact I assumed instead of checking (c0e4b89)Probed it rather than reasoning about it:
So The runtime effect was benign, as you noted. The part that matters is your second sentence: the test asserting this passed for the wrong reason. Now:
Worth naming the pattern, since this is the second test in this PR you have caught passing without proving its claim (the first was the teardown test that only exercised Verification: |
Closes #1984
A
window.setTimeoutthat outlives its test file fires after happy-dom has disposed that file'swindow, and React'sdispatchSetStatethen throws an uncaughtReferenceError: window is not defined. Vitest attributes it to whichever file happened to be running, so one leak fails the entire run with every test passing — under a step named "Enforce per-file coverage gate", which sends the reader somewhere else entirely:Seen twice in CI, both on unrelated docs-only branches, both attributed to the innocent
ServerRemoveConfirmModal.test.tsx.It is neither a Mantine bug nor a badly-written test
Both hooks do clear their timers on unmount:
It is a rAF race. The transition schedules
rAF → rAF → setTimeout, andclearAllTimeoutscancels only the pending rAF. If the inner callback is already in flight when the unmount lands,cancelAnimationFrameis a no-op and that callback goes on to schedule asetTimeoutafter cleanup has already run. Nothing owns that timer. Hitting the window requires a loaded machine, which is why it only ever appeared in CI.The guarantee we thought we had does not exist
Both
AGENTS.mdandsetup.ts's own comment assert thatenv="test"makes transitions synchronous and is "the actual protection" against this. It isn't — and believing it is why the leak went unexamined.envis read only byTransition.mjs, at its render branch:useTransition()is called at line 32, before that check — hooks cannot be conditional — and itsuseDidUpdate(…, [mounted])still runs the timer path. Measured, opening a<Modal>throughrenderWithMantine:Three real timers, in the configuration we believed suppressed them. Both docs are corrected here.
The fix
Track every timer in
setup.tsand clear whatever is still outstanding aftercleanup(). Ordering is load-bearing: legitimate unmount cleanups get their turn first, so only true leaks are dropped. Undervi.useFakeTimers()the wrapper is swapped out and nothing is tracked — correct, since a fake timer cannot outlive the environment.A component-by-component hunt was the alternative; this closes the class instead, including non-Mantine sources.
Rejected:
respectReducedMotion: trueThe obvious one-liner, since
matchMediais already pinned toprefers-reduced-motion: reduce. Measured before proposing:env="test")[200, 200, 200]+ respectReducedMotion: true[200]One survives, from
ModalBase/use-lock-scroll.mjs. Landing it alone would have made the flake rarer and harder to diagnose without fixing it.Verification
The regression test is mutation-verified — a test that cannot fail on the regression it names is worse than none:
1 failed | 4 passed5 passedIt deliberately does not assert that Mantine schedules no timers. It does, and that is normal; the contract is only that none survive the test that scheduled it.
Full
npm run ci→EXIT=0.No screenshots — test infrastructure, no web-UI or TUI surface.