fix(test): cancel queued frames before sweeping timers

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>
This commit is contained in:
cliffhall 2026-08-12 00:58:44 -04:00
commit c4e8ed992b
3 changed files with 86 additions and 16 deletions

View file

@ -17,9 +17,10 @@ import { describe, it, expect, vi } from "vitest";
import { Modal } from "@mantine/core";
import { renderWithMantine } from "./renderWithMantine";
/** Ids the net is tracking, read back through the wrapper it installed. */
function scheduleTracked(ms: number): number {
return window.setTimeout(() => {}, ms) as unknown as number;
/** Schedule through the wrapper the net installed, keeping its inferred handle
* type so it stays directly acceptable to `window.clearTimeout`. */
function scheduleTracked(ms: number) {
return window.setTimeout(() => {}, ms);
}
describe("leaked-timer safety net", () => {
@ -52,6 +53,27 @@ describe("leaked-timer safety net", () => {
expect(() => window.clearTimeout(id)).not.toThrow();
});
it("queues a frame that would schedule a timer after the sweep", () => {
// The rAF race in miniature, and the ordering the net depends on. This
// synchronous test cannot let the frame callback run — it fires only after
// the test (and the whole afterEach) returns. If frames were not cancelled
// *before* the timer sweep, this callback would register `rafScheduled`
// after the drain and survive teardown. The next test proves it does not.
requestAnimationFrame(() => {
rafRace.ranFrame = true;
window.setTimeout(() => {
rafRace.ranTimer = true;
}, 5);
});
expect(rafRace.ranFrame).toBe(false);
});
it("neither the queued frame nor its timer ever ran", async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
expect(rafRace.ranFrame).toBe(false);
expect(rafRace.ranTimer).toBe(false);
});
it("a Modal's own transition timers do not survive the test that opened it", () => {
// The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle
// `opened`, and Mantine schedules real timers for the transition and the
@ -70,3 +92,6 @@ describe("leaked-timer safety net", () => {
const leaked: { callback: ReturnType<typeof vi.fn> | null } = {
callback: null,
};
/** Records whether a queued frame — or the timer it would register — ever ran. */
const rafRace = { ranFrame: false, ranTimer: false };

View file

@ -37,13 +37,20 @@ function makeWrapper(env: "test" | "default", colorScheme: MantineColorScheme) {
};
}
// Default render helper. `env="test"` makes Mantine render transitions
// synchronously (no internal `setTimeout`). Without it, a `Transition`/`Modal`
// open/close timer can fire after happy-dom tears down `window` at the end of
// the run, throwing an uncaught `ReferenceError: window is not defined` that
// fails the whole run even when every assertion passed (#1760). This is the
// right default for the vast majority of tests, which don't assert on
// mid-transition state.
// Default render helper. `env="test"` makes Mantine skip the *animated render*
// of a transition, which is the right default for the vast majority of tests —
// they don't assert on mid-transition state.
//
// It does NOT stop the timers, contrary to what this comment used to claim
// (#1984). `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 this helper schedules three 200ms timers. A timer that outlives its
// file fires after happy-dom disposes that file's `window` and throws an
// uncaught `ReferenceError: window is not defined`, failing the whole run from
// an arbitrary innocent file (#1760). What actually prevents that is the
// leaked-timer safety net in `setup.ts`.
export function renderWithMantine(
ui: ReactElement,
options?: MantineRenderOptions,

View file

@ -113,17 +113,23 @@ Object.defineProperty(window, "matchMedia", {
// `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.
// Rather than chase each component, track every timer *and every animation
// frame*, and clear whatever is still outstanding once the test's own teardown
// has had its turn. Ordering is load-bearing twice over: this runs *after*
// `cleanup()` below, so legitimate unmount cleanups clear their own timers and
// only true leaks are left; and frames are cancelled *before* timers are swept,
// because a queued frame callback would otherwise run after the sweep and
// register a fresh timer. See the ordering note on the `afterEach` itself.
//
// 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 pendingFrames = new Set<number>();
const realSetTimeout = window.setTimeout.bind(window);
const realClearTimeout = window.clearTimeout.bind(window);
const realRequestAnimationFrame = window.requestAnimationFrame.bind(window);
const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window);
window.setTimeout = ((
handler: TimerHandler,
@ -156,11 +162,43 @@ window.clearTimeout = ((id?: number): void => {
// Same DOM-vs-Node overload mismatch as `setTimeout` above.
}) as unknown as typeof window.clearTimeout;
window.requestAnimationFrame = ((callback: FrameRequestCallback): number => {
const handle: number = realRequestAnimationFrame((time: number) => {
pendingFrames.delete(handle);
callback(time);
});
pendingFrames.add(handle);
return handle;
// Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above.
}) as unknown as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((handle: number): void => {
pendingFrames.delete(handle);
realCancelAnimationFrame(handle);
// Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above.
}) as unknown as typeof window.cancelAnimationFrame;
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.
// Order is the whole point, and it is not interchangeable.
//
// Cancel queued animation frames FIRST. This `afterEach` is synchronous, so a
// frame callback already queued cannot run until it returns — at which point
// it would register a fresh `setTimeout` *after* the sweep below had already
// drained, and on the file's last test that timer would survive teardown.
// That is precisely the rAF race this net exists for, so sweeping timers
// without cancelling frames first would leave the original hole open.
//
// Cancelling first closes it deterministically: JS is single-threaded and
// nothing here yields, so no frame callback can run between these two loops.
for (const handle of pendingFrames) {
realCancelAnimationFrame(handle);
}
pendingFrames.clear();
// Then drop the timers. After cleanup(), anything still pending is a leak.
for (const id of pendingTimers) {
realClearTimeout(id);
}