fix(test): clear leaked timers so one cannot fail an unrelated CI run

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>
This commit is contained in:
cliffhall 2026-08-12 00:16:05 -04:00
commit 1e0f94bb31

View file

@ -65,10 +65,15 @@ Object.defineProperty(globalThis, "fetch", {
// disable Mantine transitions: `useTransition` only honors reduced motion when
// `theme.respectReducedMotion` is true, and Mantine 8 defaults it to `false`
// (the project theme doesn't override it). The actual protection against the
// #1760 post-teardown `window is not defined` leak is `env="test"` in
// `renderWithMantine`, which forces transitions synchronous regardless. Tests
// that need specific media results still mock `@mantine/hooks` or stub
// `matchMedia` themselves.
// #1760 post-teardown `window is not defined` leak is the leaked-timer safety
// net below — NOT `env="test"`, which an earlier version of this comment
// claimed. `env` is read only by `Transition.mjs`, and only at its *render*
// branch (`transitionDuration === 0 || env === "test"`); `useTransition()` is
// called before that check, since hooks cannot be conditional, so it still
// schedules real `window.setTimeout`s on every `mounted` change. Measured:
// opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers
// (#1984). Tests that need specific media results still mock `@mantine/hooks`
// or stub `matchMedia` themselves.
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
@ -91,7 +96,73 @@ Object.defineProperty(window, "matchMedia", {
}) as unknown as MediaQueryList,
});
// ---------------------------------------------------------------------------
// Leaked-timer safety net (#1984)
//
// A `window.setTimeout` that outlives its test file fires after happy-dom has
// disposed that file's `window`, and React's `dispatchSetState` then throws an
// uncaught `ReferenceError: window is not defined`. Vitest attributes that to
// whichever file happened to be running, and one such error fails the ENTIRE
// run with every test passing — so it reads as a defect in an innocent file.
//
// Mantine's own hooks do clear their timers on unmount (`useTransition`'s
// `clearAllTimeouts`, `useLockScroll`'s effect cleanup), so this is not a
// library bug. It is a race: `clearAllTimeouts` cancels the *pending* rAF, but
// if that rAF callback is already in flight when the unmount lands,
// `cancelAnimationFrame` is a no-op and the callback goes on to schedule a
// `setTimeout` after cleanup has already run. Nothing owns that timer. It needs
// a loaded machine to hit, which is why it only ever appears in CI.
//
// Rather than chase each component, track every timer and clear whatever is
// still outstanding once the test's own teardown has had its turn. Ordering is
// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount
// cleanups clear their own timers and only true leaks are left.
//
// Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake
// implementation, so nothing is tracked while fake timers are installed — which
// is correct, since a fake timer cannot outlive the environment.
const pendingTimers = new Set<number>();
const realSetTimeout = window.setTimeout.bind(window);
const realClearTimeout = window.clearTimeout.bind(window);
window.setTimeout = ((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
): number => {
const id: number = realSetTimeout(
(...cbArgs: unknown[]) => {
pendingTimers.delete(id);
if (typeof handler === "function") {
handler(...cbArgs);
}
},
timeout,
...args,
);
pendingTimers.add(id);
return id;
// happy-dom types `setTimeout` with Node's overloads (returning `Timeout`),
// while the DOM lib types it as `number`. The implementation above honors the
// DOM shape the app codes against; this cast bridges the two declarations,
// which TS cannot relate structurally.
}) as unknown as typeof window.setTimeout;
window.clearTimeout = ((id?: number): void => {
if (typeof id === "number") {
pendingTimers.delete(id);
}
realClearTimeout(id);
// Same DOM-vs-Node overload mismatch as `setTimeout` above.
}) as unknown as typeof window.clearTimeout;
afterEach(() => {
cleanup();
window.localStorage.clear();
// 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);
}
pendingTimers.clear();
});