test(web): settle real-timer Transitions so the coverage gate can't leak 'window is not defined' (#1786) - #1793
Conversation
…eak 'window is not defined' (#1786) The web coverage gate was intermittently failing with an unhandled `ReferenceError: window is not defined` thrown from a Mantine `Transition` timer that fired after happy-dom tore down `window` (the #1760 class). The three `ViewHeader.test.tsx` tests that use `renderWithMantineTransitions` (real, env="default" timers) each drove their *primary* transition to completion, but the concurrent *enter* on a sibling cell leaves no unmount to `waitFor`, so its rAF→setTimeout chain could still be pending at teardown and fire post-teardown, failing an unrelated later file's run. Fix: after each such test, flush pending transition timers against the still-mounted tree via `settleTransitions()` (a wait longer than the 300ms header duration + rAF slack), so the settling setState targets a live tree and nothing is left to fire after cleanup unmounts. Also close the related nit: `renderWithMantine`/`renderWithMantineTransitions` now accept a `colorScheme` option, so `ViewHeader.test.tsx` no longer hand-rolls a bare `defaultColorScheme="dark"` MantineProvider (the #1760 anti-pattern). AGENTS.md documents both the option and the settle requirement. Closes #1786 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
@claude review |
|
Claude finished @cliffhall's task in 5m 13s —— View job Review: PR #1793 — settle real-timer Transitions
Verdict: good change — the What's right
1.
|
…rom HEADER_ANIM_MS, guard fake timers, correct comments Review feedback on PR #1793: 1. Derive the settle window from the real animation duration: export `HEADER_ANIM_MS` from ViewHeader.tsx and compute `TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200` in the test, so bumping the header duration can't silently make the settle insufficient (the invariant is now structural, not a comment). 2. Move `settleTransitions` into the shared `renderWithMantine.tsx` helper (exported, optional `ms` arg) next to `renderWithMantineTransitions`, since AGENTS.md points every future caller at it — shared test scaffolding belongs in src/test/, not a single test file. AGENTS.md now references the helper. 3. Guard `settleTransitions` against `vi.useFakeTimers()`: it awaits a real setTimeout, so under fake timers it would hang to the test timeout. Throw a clear message instead. 4. Correct the diagnosis comments: the hazard isn't a Mantine timer surviving unmount (its effect cleanup cancels timers) — it's the React state update Mantine's inner rAF schedules outside `act`, resolving post-teardown. Also fix the stale setup.ts matchMedia comment: the reduce-motion stub does not disable transitions (Mantine defaults respectReducedMotion=false); env="test" is the actual #1760 protection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks for the thorough review — all four notes addressed in 5834cd2. 1. 2. 3. Fake-timers footgun — Added a guard: 4. Diagnosis nuance — Corrected the comments so the trail isn't misleading.
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 52s —— View job Review: PR #1793 — follow-up commit
|
Comment-accuracy follow-ups from PR #1793 round 2 (test infra only, no behavior change): - DEFAULT_SETTLE_MS: drop the false "Mantine's default header transitions run ~300ms" attribution (300ms is ViewHeader's HEADER_ANIM_MS; Mantine's Transition default is 250ms). Justify 500ms generically — a typical few-hundred-ms transition plus rAF slack. - settleTransitions doc: stop asserting a single escape mechanism as fact. State what's observed (a post-teardown dispatchSetState throwing from resolveUpdatePriority, which runs synchronously inside dispatchSetState), what's known (useTransition has an unmount cleanup), and that the exact escape route isn't pinned down — the act-wrapped drain fixes it regardless. - setup.ts: happy-dom v20 does implement matchMedia, so reframe the block as a deliberate override (pinning prefers-reduced-motion: reduce) rather than a polyfill for a missing API. - Export MantineRenderOptions so callers can name the options type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
All round-2 items addressed in a60c0d1 (test-infra comments only, no behavior change) — thanks for walking your own note 4 back, that's the right call. Note 1 residual ( Note 4 (over-confident mechanism) — Softened. The
Nit —
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 33s —— View job Review: PR #1793 — round-3 (
|
| Item | Status |
|---|---|
DEFAULT_SETTLE_MS false "Mantine default header transition" attribution |
Fixed — now justified generically (renderWithMantine.tsx:55-58), with the "callers who know their duration should pass it" advice kept |
| Note 4 over-confident mechanism | Softened well (:61-78) — states the observed failure, the known useTransition cleanup, and explicitly that the escape route (happy-dom cancelAnimationFrame, an rAF re-armed post-cleanup, or a scheduler-flushed continuation) isn't pinned down, plus why the fix is route-independent |
setup.ts respectReducedMotion premise |
Corrected, theme untouched as advised — except the inner comment, finding 1 |
MantineRenderOptions not exported |
Exported (:11) |
3. Re-verified the settle window actually bounds the JS chain
Worth spelling out since finding 1 is cosmetic and this is the part that has to be right:
- The four header
Transitions all useduration={HEADER_ANIM_MS}/exitDuration={HEADER_ANIM_MS}(ViewHeader.tsx:319-419), noenterDelay/exitDelay— so the longest JS chain is one rAF pair + a 300mssetTimeout.TRANSITION_SETTLE_MS = 300 + 200strictly exceeds it. ✅ - The 150ms stagger is genuinely CSS-only:
App.css:325isanimation: inspector-fade-slide-in 300ms ease 150ms both. Total CSS time is 450ms — also inside the 500ms window, though only the JS side needs to be. ✅ - The second animation constant in the file,
TOGGLE_ANIM_MS = 200(:215, themonitorToggleslide), is not covered by theHEADER_ANIM_MS-derived formula — but it can't bite:connectedProps(ViewHeader.test.tsx:33-44) has nomonitorToggle, so thatTransitionnever mounts in the three real-timer tests, and 200 < 300 means the derived window would cover it anyway. ✅ - The new fake-timer guard can't fire spuriously: the two
vi.useFakeTimers()tests (:278,:424) don't callsettleTransitions, and both restore in afinally, so a failure inside one can't leak fake timers into a later settling test. ✅ settleTransitionsis imported (not copied) and is the sole consumer; no test inclients/web/srchand-rolls aMantineProviderany more — the only remaining match is a comment inContentViewer.test.tsx:221. ✅
4. Nit — makeWrapper's doc leads with the wrong branch
renderWithMantine.tsx:15-22: the header line says "Build a MantineProvider wrapper for the given env", then the inherited paragraph explains why env="test" is right — but this function also builds the env="default" wrappers, whose whole point is the opposite. The env="test" rationale reads more naturally on renderWithMantine itself (mirroring the env="default" rationale that already sits on renderWithMantineTransitions), leaving makeWrapper with its one-liner. Pure readability; no behavior. Fix this →
5. Follow-up worth an issue (out of scope here)
setup.ts:80 is matches: /prefers-reduced-motion/.test(query), so the stub also answers true to (prefers-reduced-motion: no-preference) — it claims both preferences simultaneously. Harmless today (Mantine queries only the reduce form, and per your own correction respectReducedMotion: false means nothing acts on it), but now that this block is documented as a deliberate override rather than a shim, the sloppy match is a latent trap for the first test that queries no-preference. Matching /prefers-reduced-motion:\s*reduce/ would make the override say what the comment says it says. Not for this PR.
Verification caveat
This checkout has no node_modules, so I could not confirm vi.isFakeTimers() against the installed vitest types, nor the happy-dom v20 matchMedia claim at source. Both are load-bearing for the code and the comment respectively; I'm relying on your green local npm run ci (which typechecks the helper) and your stated node_modules check for the happy-dom version. Everything else above was verified against the files in this branch.
Summary
Merge-ready on substance — the fix, the derivation, the guard, and the shared-helper placement are all correct and I found nothing that changes behavior. Finding 1 is the one I'd fix first: a commit whose stated purpose is comment accuracy shouldn't leave two adjacent comments asserting opposite facts, especially when one of them is doing double duty as a double-cast justification. Findings 4 and 5 are optional.
· v2/fix-1786-transition-timer-leak
…Media override (round 3) Round-3 review of PR #1793 (test infra only): - setup.ts finding 1: the inner `value:` comment still said "happy-dom doesn't implement it", contradicting the block comment two lines up (which now correctly states happy-dom v20 DOES implement matchMedia and this is an override). Dropped the false clause; kept the part that actually justifies the `as unknown as MediaQueryList` double cast (the stub omits most of the interface). - setup.ts finding 5 (folded in rather than deferred): anchored the `matches` regex to `/prefers-reduced-motion:\s*reduce/` so the override no longer also answers `true` to `(prefers-reduced-motion: no-preference)`. - renderWithMantine.tsx nit 4: the `env="test"` rationale sat on `makeWrapper`, which also builds the `env="default"` wrappers (opposite purpose). Moved it onto `renderWithMantine`, mirroring the `env="default"` rationale on `renderWithMantineTransitions`; `makeWrapper` keeps a one-liner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
All round-3 items handled in d56e342 (test-infra comments + one regex tighten; no runtime behavior change to the app). Finding 1 (contradictory adjacent comments) — Fixed. The inner Finding 5 (the Nit 4 ( Re: your verification caveat — confirmed locally: happy-dom is
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 17s —— View job Review: PR #1793 — round 4 (
|
| Item | Status |
|---|---|
Contradictory adjacent matchMedia comments (finding 1) |
Fixed (setup.ts:75-80). The false "happy-dom doesn't implement it" clause is gone; what remains — the stub omits most of MediaQueryList — is the part that actually justifies the as unknown as MediaQueryList double cast on :91, so the AGENTS.md double-cast rule is still satisfied and the block comment on :60 no longer contradicts it. |
no-preference trap (finding 5) |
Tightened to /prefers-reduced-motion:\s*reduce/ (:83). Verified safe: the only prefers-reduced-motion consumers in the repo are this stub and App.css:336 (a CSS @media, which the JS stub can't affect); the only JS matchMedia caller is hostContext.ts:30 (prefers-color-scheme: dark), false before and after. The \s* also covers the unspaced (prefers-reduced-motion:reduce) form. |
makeWrapper doc led with the wrong branch (nit 4) |
Moved — makeWrapper:15-16 is now a neutral one-liner pointing at both helpers, and the env="test"/#1760 rationale sits on renderWithMantine:27-33, mirroring the env="default" rationale on renderWithMantineTransitions:42-48. |
Also re-verified from the tree (not taken on trust):
renderWithMantineTransitionshas exactly one consumer file —ViewHeader.test.tsx— and all three of its call sites now settle (:274,:370,:420). So the leak class is closed everywhere it currently exists, not just in the test that was failing.env="default"is genuinely required by those three tests, so this couldn't have been fixed by just switching them to the safe helper:data-animis set from props, not from transition status (ViewHeader.tsx:327/361/385/401), and the render props discard Mantine's interpolatedstyles— theTransitions are keep-alive only (:348-352). Underenv="test"the duration collapses to 0 and the exiting cell unmounts synchronously, so the mid-flight"out"assertion has nothing to observe.- The settle window still strictly bounds the JS chain: all four header transitions use
duration/exitDuration={HEADER_ANIM_MS}with noenterDelay/exitDelay, so the longest chain is one rAF pair + a 300ms timeout, and300 + 200clears it.TOGGLE_ANIM_MS = 200(:215) is uncovered by the formula but can't bite —connectedPropshas nomonitorToggle, so thatTransitionnever mounts, and 200 < 300 anyway. - The fake-timer guard can't fire spuriously: the two
vi.useFakeTimers()tests (:278,:424) don't callsettleTransitions, and both restore in afinally. - Coverage-neutral:
src/test/**isn't in theincludewhitelist (vite.config.ts:180-195), so the newvi.isFakeTimers()branch and themsdefault parameter aren't gated — same as when the helper lived in a test file. AndHEADER_ANIM_MS's newexportdoesn't tripreact-refresh/only-export-components:reactRefresh.configs.vite(eslint.config.js:19) setsallowConstantExport. - The
vistatic import can't escape the test graph: the only importers ofrenderWithMantineare*.test.tsxfiles (thesetup.tshit is a comment), so nothing reaches the Storybook browser project or avite build. - Versions match the comments' premises:
happy-dom ^20.9.0,react ^19.2.4,vitest ^4.1.0,@mantine/core ^8.3.17, and nothing inclients/web/srcorcoresetsrespectReducedMotion— confirming thesetup.ts:64-67correction.
1. The settle contract is duration-only; a future caller with a delay under-settles (optional)
settleTransitions(HEADER_ANIM_MS + 200) is right for this component precisely because ViewHeader sets no enterDelay/exitDelay — a fact the PR body states but the helper's doc doesn't. renderWithMantine.tsx:57-60 tells the next caller to pass "duration + slack", which silently under-settles a Transition with a delay, or a component that chains two transitions. One clause — "duration plus any enterDelay/exitDelay plus rAF slack" — makes the formula complete at the place the next person reads it. Cheap, and it's the same class of fix as note 1 from round 1 (make the invariant legible where it's used, not only where it was derived). Fix this →
2. Settling is still a convention AGENTS.md has to police (follow-up, not this PR)
The failure mode this PR fixes was "the author forgot the concurrent enter had no DOM signal." After the PR, the defense against a recurrence is a paragraph in AGENTS.md plus a comment on the helper — i.e. the same class of defense that failed the first time. Since renderWithMantineTransitions already owns the wrapper, it could own the settle too: register a settle on test completion (vitest's onTestFinished, or an afterEach the helper installs), skipping it when fake timers are active. Then a real-transitions test can't leak by omission, and the three explicit calls in ViewHeader.test.tsx become redundant rather than load-bearing.
The load-bearing constraint if anyone tries this: the settle must run before the global cleanup() in setup.ts:94-97 unmounts the tree — that ordering is the whole reason the current act-wrapped drain works. I can't verify onTestFinished's ordering relative to afterEach from this checkout (no node_modules), so that needs confirming before it's worth doing; if it runs after cleanup(), the idea is dead and the convention stands. Worth an issue, not a change here.
Nit
ViewHeader.tsx:57-62's comment explains the CSS-sync duty of HEADER_ANIM_MS but not that it's now exported because ViewHeader.test.tsx derives its settle window from it — which is exactly the kind of "why is this exported?" a future tidy-up removes. Half a sentence at the definition site would pin it.
Verification caveat
This checkout has no node_modules, so vi.isFakeTimers() (vitest 4), happy-dom 20's matchMedia implementation, and Mantine's useTransition internals were checked against versions in package.json and documented behavior, not installed source. All are consistent with your local green npm run ci. Everything else above was verified against files on this branch. CI run 30210638880 is still in progress as I write this.
Summary
Three rounds in, the substance has been right since 5834cd24 and this commit finished the comment trail — the code, the derived settle window, the fake-timer guard, the shared-helper placement, and the tightened matchMedia override all check out, and no other test in the suite opens real transitions without settling. Both notes above are optional; note 2 is a follow-up issue at most.
· v2/fix-1786-transition-timer-leak
Round-4 review of PR #1793 — implements the note-2 hardening in-PR rather than deferring it, plus note 1 and the nit: - Note 2 (settle was a convention AGENTS.md had to police): the real-transitions helper now OWNS the settle. `renderWithMantineTransitions` arms an automatic post-test drain via a helper-registered `afterEach`, so a real-transitions test can no longer leak the #1760 class by forgetting to settle a concurrent enter. Verified empirically that the helper's `afterEach` runs BEFORE the global cleanup() in setup.ts (afterEach is LIFO; setup.ts's cleanup is a setupFile registered first) — the ordering the drain depends on. The three explicit `settleTransitions()` calls in ViewHeader.test.tsx are gone; each render passes `settleMs: HEADER_ANIM_MS + 200` instead, keeping the window duration-derived. The auto-settle skips under fake timers (and no transitions test uses them). `settleTransitions` stays exported for the rare manual mid-body case. - Note 1 (settle formula was duration-only): the helper doc + the new `settleMs` option doc now say the window must exceed the longest JS chain — duration PLUS any enterDelay/exitDelay — plus rAF slack. - Nit: ViewHeader.test.tsx now states why HEADER_ANIM_MS is exported (the settle window must track the animation it settles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-4 items handled in 0ce9dae — including note 2, implemented in-PR (I have Note 2 (settling was a convention AGENTS.md had to police) — The ordering you correctly called load-bearing — the settle must run before setup.ts's Note 1 (settle formula was duration-only) — The helper doc and the new Nit —
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 44s —— View job Review: PR #1793 — round 5 (
|
…imer doc; keep longest settle (round 5) Round-5 review of PR #1793: - Note 2 (load-bearing hook ordering was unpinned/undefended): the auto-settle's afterEach must run before setup.ts's cleanup(). Added BOTH guards the review suggested. (1) Runtime self-check: renderWithMantineTransitions captures the render container; the afterEach throws a clear error if it's already detached (cleanup ran first). (2) Pinned `sequence.hooks: "stack"` on the unit project. While verifying, found the ordering actually holds in EVERY sequence.hooks mode (stack/list/parallel) because setupFile hooks are outer relative to the helper's import-registered hook — so the comments now state that true mechanism (the pin is defense-in-depth; the container.isConnected check is the real enforcement), rather than claiming it "depends on stack". Full 3533-test unit suite passes under "stack". - Note 1 (fake-timer claim was backwards): AGENTS.md no longer says the leak "would return" under fake timers (fake clocks park Mantine's setTimeout/rAF and are discarded, not drained). The real hazard — the auto-settle silently no-ops, so real-clock work is unprotected — is now stated. The afterEach also console.warns on the fake-timer skip (was silent; the manual settleTransitions throws — this closes that asymmetry). - Note 3 (arming was last-write-wins): armedSettleMs now keeps the longest window via Math.max, so a later short-animation render can't under-settle an earlier long one. - Nit: documented `settleMs: 0` as a working opt-out for a test that drove every transition to completion itself (act flush, no wait). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-5 items handled in 905cbfe — including both guards from note 2, plus a mechanism correction I found while verifying. Note 2 (load-bearing ordering unpinned/undefended) — Added both guards you suggested:
While wiring these up I verified the actual ordering (I have Note 1 (fake-timer claim backwards) — You're right, and thanks for the correction. AGENTS.md no longer says the leak "would return" under fake timers; it now states the actual hazard (the auto-settle silently no-ops, so anything left on the real clock is unprotected and the test depends on which clock was installed at teardown), and notes that fake timers park Mantine's Note 3 (last-write-wins arming) — Nit — Documented
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 11s —— View job Review: PR #1793 — round 6 (
|
| Item | Status |
|---|---|
| Note 2a — runtime self-check | Added (renderWithMantine.tsx:154-165). armedContainer captured at render (:77), checked before settling, with a self-explaining error naming the ordering assumption and the pin. |
| Note 2b — pin the ordering | sequence: { hooks: "stack" } on the unit project (vite.config.ts:306) with the rationale above it. |
| Note 1 — fake-timer claim | Corrected in AGENTS.md — now states the actual hazard (the settle silently no-ops, so real-clock work is unprotected and the test depends on which clock was installed at teardown) and explicitly notes fake timers park Mantine's timers on a discarded clock. Accurate. |
| Note 1b — silent-skip asymmetry | Closed: console.warn on the auto-settle skip (:146-153), manual settleTransitions still throws (:115-121). |
| Note 3 — last-write-wins arming | Math.max(armedSettleMs ?? 0, settleMs) (:72) with a comment saying why. |
Nit — settleMs: 0 opt-out |
Documented (:19-20), and it works: Math.max(null ?? 0, 0) === 0, ms === null is false, so the act flush runs with a zero-delay wait. |
Also re-verified from the tree: renderWithMantineTransitions still has exactly one consumer file, all three call sites pass settleMs: TRANSITION_SETTLE_MS, no explicit settleTransitions() calls remain, TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200 is intact, the two vi.useFakeTimers() tests (:277, :419) use renderWithMantine and restore in a finally, and src/test/** stays outside the coverage include so the new branches aren't gated.
1. The self-check is blind in parallel mode — which is also why the mode probe isn't evidence there
renderWithMantine.tsx:157 reads container.isConnected synchronously, before the first await:
if (container && !container.isConnected) { throw … } // sync
await settleTransitions(ms); // first yieldUnder sequence.hooks: "parallel", Vitest starts same-level afterEach callbacks concurrently. The helper's body therefore gets to run its sync check first (registration order for entry), passes it because nothing has unmounted yet, then yields at the await — at which point setup.ts's fully-synchronous cleanup() runs to completion and detaches the tree. The settle then drains against an unmounted tree and returns quietly.
So in parallel mode, "the guard never trips" is exactly what the lost race looks like, not evidence the ordering held. That undercuts two claims added in this commit:
vite.config.ts:299-302/ AGENTS.md: "That ordering already holds in everysequence.hooksmode here … (verified across stack/list/parallel)" — forparallelthe probe can't distinguish "ordering held" from "guard blind". (listis a real signal: cleanup registered first would run first and the guard would trip, so that one does tell you setupFile hooks are outer.)renderWithMantine.tsx:132-135: "Thecontainer.isConnectedcheck below is the actual enforcement … regardless of what guarantees it." It's enforcement against sequential mis-ordering only.
The fix is one line — re-assert after the settle, where a concurrent cleanup() is observable:
await settleTransitions(ms);
if (container && !container.isConnected) throw new Error(/* …drained after unmount… */);That makes the check mechanism-independent for real (catches list and the parallel race), and it's what lets the stack pin honestly be called defense-in-depth. Given this PR's whole thesis is "a green run is non-evidence," a guard that can't fail in the racy mode is worth closing. Fix this →
2. Nits
armedContaineris last-write-wins whilearmedSettleMsis now max-wins (:72vs:77). Harmless in practice —cleanup()detaches every mounted container at once, so checking the last one is a valid proxy for "cleanup ran" — but the asymmetry will read as an oversight next time someone edits this. A word on:91-92saying why the most recent container suffices would settle it.- The skip warning fires during teardown, after the test's result line is printed, so it appears detached from the test that caused it. Interpolating
expect.getState().currentTestNameinto the message would make it directly actionable — cheap, and this warning exists precisely for someone who doesn't yet know what they did wrong. settleMs: 0still costs a macrotask tick + anactflush per render, which is the point; worth knowing it isn't literally free.
Verification caveat
This checkout has no node_modules, so I could not run the suite or read Vitest 4 at source. Two things rest on that: (a) finding 1's premise that sequence.hooks: "parallel" starts same-level after-hooks concurrently (documented behavior, and consistent with the round-4 caveat we both flagged) — worth a 2-minute confirmation before you act on it; and (b) that sequence is an accepted project-level key rather than root-only, which would make the pin a silent no-op. Your green npm run ci typechecks vite.config.ts, so (b) is probably fine, but I couldn't confirm it against the installed types. Everything under "Round-5 items — confirmed" was verified against files on this branch. CI run 30211731234 is in progress as I write this.
Summary
Substance has been right since 5834cd24; rounds 4–5 turned the convention into a mechanism and this round added the guards. The one thing I'd change before merge is finding 1 — not because the fix is wrong (it isn't; stack is what actually runs, and under stack the ordering is correct and the settle does its job), but because the newly-added comments assert an enforcement strength the check doesn't have in the one mode where it matters. Post-settle re-check + a one-clause correction to the three comments and this is done.
· v2/fix-1786-transition-timer-leak
…m (round 6) Round-6 review of PR #1793: - Finding 1 (self-check blind in parallel): added the post-await container.isConnected re-check the review suggested (factored both checks into assertConnectedForSettle(container, "before"|"after")). While verifying it, I established the actual mechanism, which differs from what both prior comments and the review assumed: setup.ts's cleanup() is a *setupFile (outer)* hook, and Vitest runs inner afterEach hooks to completion before outer ones in EVERY sequence.hooks mode — so cleanup never races this settle. Verified: ViewHeader's real-transitions tests pass under stack/list/parallel with neither check tripping, and a same-suite `afterEach(cleanup)` probe (where the ordering CAN break) does trip them (pre-check). So sequence.hooks isn't load-bearing here; the comments in renderWithMantine.tsx, vite.config.ts, and AGENTS.md now say that. The "stack" pin stays as documentation of intent / defense-in-depth, and the two checks are the real guard against a future same-level-cleanup regression. - Nit: documented why last-write-wins on armedContainer is fine (cleanup detaches every container at once, so the last is a faithful "cleanup ran" proxy). - Nit: the fake-timer skip warning now interpolates the current test name so it's actionable at teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-6 handled in 7cd00be — I took the post-await check you suggested, and in wiring it up nailed down the actual mechanism, which turns out to differ from what both of us were assuming. Finding 1 (self-check blind in parallel) — Added the post-await re-check (both checks are now one
So Nits — Documented why last-write-wins on On your verification caveat (b): |
|
@claude review |
|
Claude finished @cliffhall's task in 3m 33s —— View job Review: PR #1793 — round 7 (
|
| Item | Status |
|---|---|
| Post-await re-check | Added — both checks funnel through assertConnectedForSettle(container, "before"|"after") (renderWithMantine.tsx:164-168, :175-189), with per-position wording ("entirely first" / "concurrently mid-settle"). |
| Correct the "parallel race" framing at all three sites | Done, and consistently: renderWithMantine.tsx:129-144, vite.config.ts:293-305, AGENTS.md all now say the same thing — outer-setupFile ordering is the guarantee, stack is defense-in-depth only, the two isConnected checks are the enforcement. No site still claims sequence.hooks is load-bearing. |
| Adversarial probe is reported, not just the green one | Good call — recording that a same-suite afterEach(cleanup) does trip the pre-check is what makes these checks demonstrably live rather than unfalsifiable decoration. That's the piece my round-6 note was actually reaching for. |
armedContainer last-write-wins rationale |
Documented (:91-94) — cleanup detaches every mounted container at once, so the last is a faithful proxy. Correct. |
currentTestName in the skip warning |
Interpolated (:157). |
Also re-verified from the tree: renderWithMantineTransitions still has exactly one consumer file, all three call sites pass settleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200 (ViewHeader.test.tsx:20, :243, :316, :389), no explicit settleTransitions() calls remain, the two vi.useFakeTimers() tests use renderWithMantine and can't arm the settle, settleMs: 0 still works (Math.max(null ?? 0, 0) === 0, ms === null false → act flush, no wait), and src/test/** stays outside the coverage include so the new branches aren't gated.
1. The guard says "cleanup ran" but only knows "detached" — a test that unmounts itself gets a misdiagnosis (worth fixing)
assertConnectedForSettle fires on !container.isConnected, and the error asserts a specific cause: "setup.ts's cleanup() ran entirely first … the afterEach ordering it depends on broke." But detachment has a second, entirely legitimate cause: the test called the unmount() that renderWithMantineTransitions returns.
That isn't hypothetical for this codebase — unmount() is a routine pattern here (30+ test files use it). So the first future real-transitions test that asserts unmount behavior (e.g. "no throw when the header unmounts mid-crossfade" — squarely in this helper's problem domain) fails in afterEach with an error accusing the harness of an ordering regression that didn't happen. Debugging that costs exactly what this PR has been spending six rounds paying down.
The clean fix is to make arming reflect reality rather than widen the message: wrap the returned unmount so it disarms (clears armedSettleMs/armedContainer). That's also the correct behavior, not just quieter — once the test has unmounted, there's nothing left to settle against a live tree, and Mantine's useTransition unmount cleanup is the applicable mechanism. Minimum viable alternative: name the second cause in the message ("…or the test unmounted the tree itself, in which case disarm instead of settling"). Fix this →
2. Nits
- Arming is module-level, so it isn't concurrency-safe. With
describe.concurrent/test.concurrentin a real-transitions file, two tests sharearmedSettleMs/armedContainer: the max-wins accumulation crosses test boundaries and theafterEachmay check a container belonging to a different in-flight test — which would make the guard fire spuriously (the other test's tree got unmounted). Nothing does this today and I wouldn't add machinery for it; one clause on the state comment (:88-94) saying "assumes sequential tests — don't use withtest.concurrent" would keep the next person from discovering it the hard way. expect.getState().currentTestNamecan beundefined, in which case the warning readsskipped for "undefined". A?? "(unknown test)"keeps the message from looking broken in the one path that exists to be read by a confused author.- Manual
settleTransitions()doesn't disarm, so a test that settles mid-body still pays the full auto-settle at teardown. Correct-but-slow; harmless while there are no such tests.
3. On the documentation weight (optional, and a judgement call I'd leave to you)
The AGENTS.md bullet is now a single ~450-word paragraph covering the option, the settle contract, the fake-timer hazard, the hook-ordering mechanism, and the self-checks. Everything in it is accurate and hard-won — but the helper's own comments (renderWithMantine.tsx:98-144) now carry the same derivation at more depth, and AGENTS.md's job is the rule ("use renderWithMantine; use the transitions variant only for mid-flight assertions and pass a duration-derived settleMs"). Trimming the mechanism half to a pointer at the helper would make the rule findable again without losing the trail. Not a defect; just the point at which more prose stops buying more safety.
Verification caveat
This checkout has no node_modules, so I could not run the suite, read Vitest 4's setup-file hook registration at source, or confirm sequence is honored as a project-level key. The comments' central claim (setupFile afterEach is outer relative to an import-registered hook, in all three modes) rests on your probes; the "list" result is genuinely discriminating, so I'm treating it as established. Everything under "Round-6 items — confirmed" was verified against files on this branch. CI run 30212190994 is in progress as I write this.
Summary
Six rounds in, the mechanism is now stronger than the story that motivated it: the settle is owned by the helper that creates the hazard, the window is derived from the animation it settles, the fake-timer paths both signal, and the ordering invariant has a check that has been shown to fire when broken. I'd merge. Finding 1 is the only one I'd act on before doing so — a guard whose failure message names a cause it can't distinguish is the same category of misleading trail this PR spent rounds 2–4 removing from the comments.
· v2/fix-1786-transition-timer-leak
…age nits (round 7) Round-7 review of PR #1793: - Finding 1 (guard misdiagnoses a self-unmount): the detached-tree check fired on ANY detachment and blamed a broken cleanup ordering, but a test calling the unmount() returned by renderWithMantineTransitions also detaches the container (a common pattern here). Fixed at the source: the helper now wraps unmount() to disarm the auto-settle (clear armedSettleMs/armedContainer) — once the test has unmounted there's nothing to settle against a live tree. The guard message also now names the self-unmount cause. Verified: a probe that unmounts a real-transitions render no longer trips the guard; ViewHeader unaffected. - Nit: documented that the module-level arming assumes sequential tests (not safe under test.concurrent/describe.concurrent). - Nit: the fake-timer skip warning falls back to "(unknown test)" when currentTestName is undefined. - Finding 3 (doc weight): trimmed the AGENTS.md bullet to the *rule* (use the transitions variant only for mid-flight assertions; pass a duration-derived settleMs; no fake timers; use the returned unmount()) and pointed at the helper's own comments for the full mechanism, which now carries it at more depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-10 handled in c0a1383 — thanks for tracing finding 1 across all ten commits; that regression was mine (the round-7 trim) and you're right it's the worst place for it. Finding 1 (AGENTS.md fake-timer clause regressed) — Restored the round-5 framing that the round-7 doc trim silently reverted. It no longer says "silently dropping the protection": "silently" was made false by round 5's Finding 3 (warning wording) — Matched the Finding 2 (drain-time throw could discard the liveness error) — Closed. The drain error is now captured too, and the throw order prefers the liveness error over the drain error — so the explanatory error always wins, even if
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 16s —— View job Review: PR #1793 — round 11 (
|
| Item | Status |
|---|---|
| Finding 1 — AGENTS.md fake-timer clause regressed | Fixed (AGENTS.md:208). Now reads "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." Both defects gone: "silently" (falsified by the round-5 console.warn) and "dropping the protection" (the framing round 5 walked back). Matches the helper it defers to. |
| Finding 3 — warning wording out of step | Matched (renderWithMantine.tsx:187-189): "anything left pending on the real clock is then unprotected (this settle can't drain it), so the test depends on which clock was installed at teardown." AGENTS.md and the runtime warning now say the same thing. |
| Finding 2 — a drain-time throw could discard the liveness error | Closed (:204-224). captureLiveness() records, the drain runs in its own try, and the throw order prefers the liveness error. The no-unsafe-finally avoidance (capture-then-throw rather than throw-in-finally) is the right call. |
Re-verified end to end against the tree:
- Lifecycle:
resetArming()still precedes every early return and the drain, so nothing leaks between tests even if the settle throws. Empty liveness set →some()isfalse→ no throw, so a test that unmounted its only tree drains cleanly.settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - The drain's own fake-timer throw is unreachable from the
afterEach— thevi.isFakeTimers()skip returns before it — sodrainErrorreally can only be anact-surfaced error, as the new comment claims. - Consumers:
renderWithMantineTransitionsstill has exactly one consumer file; all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:21,:243,:316,:389). The four headerTransitions all useduration/exitDuration={HEADER_ANIM_MS}with noenterDelay/exitDelay(ViewHeader.tsx:322-397), so the window still strictly bounds the JS chain. TOGGLE_ANIM_MS = 200still can't bite: the only two tests that passmonitorToggle(:137,:174) userenderWithMantine, so thatTransitionnever mounts under real timers — and 200 < 300 regardless.- Fake-timer tests can't arm:
:277and:419both render viarenderWithMantineand restore in afinally. - The
vistatic import can't escape the test graph: the only importers ofrenderWithMantineare*.test.tsxfiles (src/test/setup.ts's hit is a comment), so nothing reaches the Storybook browser project or avite build. No test hand-rolls aMantineProvider(only match is a comment atContentViewer.test.tsx:221). - Coverage-neutral: the
includewhitelist (vite.config.ts:180-195—components/hooks/theme/lib/utils/server+core/*) excludessrc/test/**, so the newtry/catchbranches aren't gated.sequenceis inside the unit project'stestobject (:305), so the pin is real, not a silently-ignored root key.
1. The discarded drainError is now a total loss (optional)
:223-224 throws the liveness error and drops drainError on the floor. Round 10 asked that a drain throw not substitute for the explanatory error; the fix substitutes in the other direction with no residue — so an act-surfaced component error during the settle vanishes entirely on that path. Attaching it ({ cause: drainError }) or a one-line console.error before the throw keeps both facts without changing which error fails the test. Reaching this needs a liveness failure and a drain throw simultaneously, so this is completeness, not a bug. Fix this →
2. The guard's error message routes the reader to the pin that rounds 6–7 established isn't load-bearing (optional)
:242-244 tells whoever trips the check to "see the ordering note here and the sequence.hooks: "stack" pin in vite.config.ts" — but the file's own comments (:163-164) and vite.config.ts:293-304 both say the pin is defense-in-depth and not what guarantees the ordering. So the one person who ever reads this message is pointed first at the thing that can't be the cause. The actual reachable cause is named 20 lines up (:167 — cleanup moved to a same-level hook, the case your round-6 probe showed does trip this); folding that into the message instead of the pin pointer would route them correctly. Same comment-consistency class as round 10's finding, one step deeper into the diagnostic path. Fix this →
Nit
livenessError ??= error (:209) is belt-and-braces given captureLiveness("after") only runs when livenessError === undefined. Harmless, and arguably the safer form if that guard ever moves.
Verification caveat
This checkout has no node_modules, so I could not run the suite or confirm vi.isFakeTimers() / project-level sequence against installed types. The hook-ordering claims rest on your rounds 5–7 probes (the list result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Everything in the confirmation section was verified against files on this branch. CI run 30213547440 is in progress as I write this.
Summary
Ten rounds in, this is finished work and the trail is consistent: helper-owned settle, duration-derived window, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same corrected framing, liveness checks demonstrated to fire when broken, and a drain that can't be skipped by its own guard nor have its explanatory error stolen. Both notes above are optional polish on an error path that only fires if someone breaks the harness.
· v2/fix-1786-transition-timer-leak
…e to the reachable cause (round 11) Round-11 review of PR #1793 (merge-verdict; two optional polish items): - Finding 1: when both a liveness error and a drain-time throw occur, the drain error was dropped entirely. It's now attached as the liveness error's `cause` (when the liveness error is an Error), so an act-surfaced component error from the settling render is preserved without changing which error fails the test. - Finding 2: the guard's error message pointed the reader first at the `sequence.hooks: "stack"` pin, which the file itself documents as defense-in-depth and NOT the ordering guarantee. Reworded to name the actually- reachable cause first — cleanup moved to a same-level afterEach instead of the setup.ts setupFile (outer) hook — and demote the pin to a parenthetical, so a maintainer who trips the check isn't sent to investigate a non-causal setting. Verified: a bare mid-body cleanup() throws the reworded message; eslint clean; ViewHeader unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-11 handled in 702c541 — both optional items taken. Finding 1 (discarded Finding 2 (message routed to the non-causal pin) — Reworded the guard's error to name the actually-reachable cause first: cleanup having been moved to a same-level On the nit: left Verified: a bare mid-body |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 1s —— View job Review: PR #1793 — round 12 (
|
| Item | Status |
|---|---|
Finding 1 — drainError was a total loss |
Fixed (renderWithMantine.tsx:225-230). The drain error is attached as the liveness error's cause, guarded on instanceof Error, and the liveness error still wins — so which error fails the test is unchanged. assertLiveContainersConnected constructs its Error without a cause, so the assignment can't clobber anything. |
| Finding 2 — message routed to the non-causal pin | Reworded (:243-257). The sequence.hooks pin is now a parenthetical explicitly labelled defense-in-depth, and the same-level-afterEach case is named as the likely cause — consistent with :155-172 and vite.config.ts:293-304. |
Nit — livenessError ??= error |
Left as the nullish-assign, as discussed. Agreed. |
Re-verified end to end against the tree:
- Lifecycle intact:
resetArming()still precedes every early return and the drain, so nothing leaks between tests even if the settle throws. Empty liveness set →some()isfalse→ no throw, so a test that unmounted its only tree drains cleanly.settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - Consumers unchanged:
renderWithMantineTransitionsstill has exactly one consumer file, and all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:21,:243,:316,:389). NosettleTransitions()call sites remain; no test hand-rolls aMantineProvider. - Window still bounds the JS chain: the four header
Transitions useduration/exitDuration={HEADER_ANIM_MS}with noenterDelay/exitDelay, so300 + 200strictly exceeds one rAF pair + the terminal 300ms timeout.TOGGLE_ANIM_MS = 200is still unreachable under real timers (the two tests that passmonitorToggleuserenderWithMantine) and is under 300 anyway. - Fake-timer tests can't arm (
ViewHeader.test.tsx:277,:419both render viarenderWithMantine, both restore in afinally), and the drain's own fake-timer throw stays unreachable from theafterEachbehind thevi.isFakeTimers()skip — sodrainErrorreally can only be anact-surfaced error, as the comment claims. - Coverage-neutral:
src/test/**is outside theincludewhitelist (vite.config.ts:180-195), so the newinstanceof Errorbranch isn't gated.sequenceis inside the unit project'stestobject (:305), so the pin is real rather than a silently-ignored root key.
1. The reworded message dropped the hedge on a cause the check can't observe (optional)
Round 11 turned
Expected cause:setup.ts's cleanup() ran entirely first
into
— setup.ts's cleanup() ran entirely first, so the settle drained nothing…
The check observes exactly one fact: a container the test left mounted is no longer connected. It cannot distinguish setup.ts's cleanup() from a bare mid-body cleanup() (the only route that's actually reachable today — the one your round-8 probe exercises) or from manual DOM surgery. So the sentence now opens by asserting the hypothetical cause as fact, promotes the other hypothetical (cleanup moved to a same-level hook) to "the likely cause", and leaves the reachable one to the closing sentence. The old Expected cause: prefix carried exactly the hedge that's now missing.
This is minor and it's within the message this commit improved — but it's the same pattern rounds 2–4 spent removing from the comments (asserting a mechanism the evidence doesn't pin down), so it's worth not reintroducing at the one place a maintainer reads when the harness breaks. Stating the observation first, then the candidates, costs a clause. Fix this →
2. Nits
- The
causeonly pays off if the reporter prints it. Vitest surfaceserror.causein its diff output, so this should be visible — but I can't confirm against installed types here, and if it ever isn't, the preserved drain error is invisible rather than lost. If you want belt-and-braces, aconsole.error(drainError)immediately before the throw guarantees it's readable regardless of reporter behavior. Not worth another round on its own. livenessError instanceof Erroris unreachable-false today (the only producer throws anError), so theelsebranch silently dropsdrainError. Correct as defensive code; just noting the drop is silent if that ever changes.
Verification caveat
This checkout has no node_modules, so I could not run the suite or confirm vi.isFakeTimers(), project-level sequence, or Vitest's cause rendering against installed versions. The hook-ordering claims rest on your rounds 5–7 probes (the list result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Everything in the confirmation section was verified against files on this branch.
Summary
Eleven rounds in, this is done: helper-owned settle, a window derived from the animation it settles, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same corrected framing, liveness checks demonstrated to fire when broken, a drain that can't be skipped by its own guard, and now an explanatory error that can't be stolen or swallow the drain's. The one note above is a clause of hedging in an error string that only fires if someone breaks the harness — take it or merge without it.
· v2/fix-1786-transition-timer-leak
…log the preserved drain error (round 12) Round-12 review of PR #1793 (merge-verdict): - Finding 1: the round-11 rewording had dropped the "Expected cause:" hedge and asserted setup.ts's cleanup() as the fact, but the check only observes that a still-live armed tree was detached — it can't identify what detached it. Reworded to state that observation first, then list candidate causes: (a) a bare mid-body cleanup() in the test (the reachable case today), or (b) cleanup moved off the setup.ts setupFile hook to a same-level afterEach. No single cause asserted as fact. - Nits 2a/2b: when both a liveness error and a drain error fire, the drain error is now also console.error'd (in addition to being attached as `cause`), so it's readable regardless of whether the reporter renders `error.cause` and isn't silently dropped even if the liveness error somehow isn't an Error. Verified: a bare mid-body cleanup() throws the reworded message; eslint clean; ViewHeader unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-12 handled in fd71da2. Finding 1 (message asserted a cause the check can't observe) — Reworded so it states the observation first — a still-mounted armed tree was detached before/while the settle ran, so the settle drained nothing and #1760 is reopened — then lists candidate causes without asserting one: (a) a bare mid-body Nits 2a/2b — When both a liveness error and a drain error fire, the drain error is now Verified: a bare mid-body |
|
@claude review |
|
Claude finished @cliffhall's task in 1m 38s —— View job Review: PR #1793 — round 13 (
|
| Item | Status |
|---|---|
| Finding 1 — message asserted a cause the check can't observe | Fixed (:252-267). Now states the observation ("a still-mounted armed tree was detached before the settle started / while the settle was in flight"), then lists candidates (a) bare mid-body cleanup() — the reachable case, with the returned unmount() as the fix — and (b) cleanup() moved off the setupFile hook, with the sequence.hooks pin demoted to a parenthetical labelled defense-in-depth. No single cause asserted; matches :155-172 and vite.config.ts:293-304. |
Nit 2a — cause only pays off if the reporter prints it |
Closed (:232-236): console.error alongside the cause, so the drain error is readable regardless of reporter behavior. |
Nit 2b — silent drop when livenessError isn't an Error |
Closed: the log now sits outside the instanceof check, so the drain error survives on that (currently unreachable) path too. |
Re-verified end to end against the tree:
- Lifecycle intact:
resetArming()still precedes every early return and the drain, so nothing leaks between tests even if the settle throws. Empty liveness set →some()isfalse→ no throw, so a test that unmounted its only tree drains cleanly.settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - Consumers unchanged:
renderWithMantineTransitionsstill has exactly one consumer file, all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:243,:316,:389), nosettleTransitions()call sites remain, and the AGENTS.md bullet still matches the helper (fake-timer clause carries the round-10 correction; theunmount()clause says "drops that tree from the settle's liveness check, while still draining"). - The new
console.errorcan't fire on a green run: it needs a liveness failure and a drain throw simultaneously, and the drain's own fake-timer throw is unreachable from theafterEachbehind thevi.isFakeTimers()skip. So no new console noise in the suite (AGENTS.md's "suppress expected error output" rule isn't engaged). - Coverage-neutral:
src/test/**is outside theincludewhitelist (vite.config.ts:180-195), so the new branch isn't gated.
1. The log message asserts the cause attachment that the line above makes conditional (optional)
renderWithMantine.tsx:231-236:
if (livenessError instanceof Error) livenessError.cause = drainError; // conditional
console.error(
"…the drain also threw (reported as the liveness error's cause):", // unconditional
drainError,
);Nit 2b was fixed by hoisting the log out of the instanceof guard — correct — but the message text still claims the cause was attached. On the non-Error path (the one the hoist exists to serve) the reader is told to look for a cause that isn't there. Unreachable today (assertLiveContainersConnected only throws an Error), so this is wording, not behavior — but it's the same pattern round 12 removed from the guard string one commit ago, in the same file, and the defensive branch exists precisely for the case where that producer changes. Dropping the parenthetical (or moving it inside the instanceof branch) makes the message true on both paths. Fix this →
2. Nits
- The comment block at
:220-224is now a stale duplicate of the one it introduced at:227-230. Two of its three sentences are restated near-verbatim 5 lines down, and the inner copy is the more accurate one (it mentions the log and theinstanceofconditional; the outer says flatly "Preserve the drain error as itscause"). Trimming the outer to just the priority rationale — "prefer the liveness error; a drain-time throw is a downstream symptom" — leaves one description of the mechanism instead of two that differ. This is the "append prose" drift you and I agreed to avoid a few rounds back; cheapest place to correct it is while finding 1 is open. - "so the settle drained nothing" is exact for
when === "before"but overstated for"after"— a tree detached mid-settle was live for part of the drain. One word ("drained against a dead tree", which the function's own comment at:245already uses) covers both. - "see the ordering note here" inside a thrown string reads oddly out of context; the reader has the stack, so it resolves, but naming
renderWithMantine.tsxwould make it self-contained.
Verification caveat
This checkout has no node_modules, so I could not run the suite or confirm vi.isFakeTimers(), project-level sequence, or Vitest's cause rendering against installed versions. The hook-ordering claims rest on your rounds 5–7 probes (the "list" result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Everything in the confirmation section was verified against files on this branch.
Summary
Twelve rounds in and the last three have been polishing strings on an error path that only fires if someone breaks the harness — which is a good sign the substance is done. The mechanism stands: helper-owned settle, window derived from the animation it settles, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same framing, liveness checks demonstrated to fire when broken, a drain that can't be skipped by its own guard, and an explanatory error that can neither be stolen nor swallow the drain's. Take finding 1 or don't — either way this is ready.
· v2/fix-1786-transition-timer-leak
…d 13) Round-13 review of PR #1793 (merge-verdict; string/comment polish only): - Finding 1: the console.error text claimed the drain error was "reported as the liveness error's cause" unconditionally, but the cause is only attached when the liveness error is an Error — misleading on the very non-Error path the hoisted log exists to serve. Dropped that parenthetical so the log is true on both paths. - Nit: trimmed the now-duplicated outer comment to just the priority rationale (the inner comment, which mentions the log + instanceof conditional, is the accurate one). - Nit: guard message says "drained against a dead tree" (exact for both the before- and during-settle detach) instead of "drained nothing". - Nit: the message now names renderWithMantine.tsx rather than "the ordering note here", so it's self-contained out of context. eslint clean; ViewHeader unchanged; npm run ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-13 handled in 6596a6d — all string/comment polish.
|
|
@claude review |
|
Claude finished @cliffhall's task in 4m 23s —— View job Review: PR #1793 — round 14 (
|
| Item | Status |
|---|---|
Finding 1 — log claimed a cause the line above only conditionally sets |
Fixed (:230-233). The message is now just "the drain also threw:", true on both the instanceof Error and non-Error paths; the inner comment (:224-228) states the conditional attachment explicitly. |
| Nit — duplicated comment block | Trimmed (:220-221 is now the priority rationale only); the inner comment is the single description of the preservation mechanism. |
Nit — "drained nothing" overstated for the "after" case |
Now "drained against a dead tree" (:256), which matches the function's own doc at :242 and is exact for both positions. |
| Nit — "see the ordering note here" | Self-contained: names renderWithMantine.tsx (:262-263). |
Re-verified end to end against the tree (not taken on trust):
- Lifecycle intact.
resetArming()still precedes every early return and the drain (:176), so state can't leak between tests even if the settle throws. Empty liveness set →some()isfalse→ no throw, so a test that unmounted its only tree drains cleanly.settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - Consumers unchanged.
renderWithMantineTransitionsstill has exactly one consumer file; all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:21,:245,:318,:395); the twovi.useFakeTimers()tests (:277,:419) render viarenderWithMantineso they can't arm; nosettleTransitions()call sites remain. - The
vi/afterEachmodule scope still can't escape the test graph. The only non-test importer ofrenderWithMantineissrc/test/setup.ts— and that's a comment, not an import. No*.stories.tsximports it, so the top-levelafterEachand thevitestimport never reach the Storybook browser project or avite build. - New console output can't fire on a green run. The
console.errorneeds a liveness failure and a drain throw simultaneously; the drain's own fake-timer throw is unreachable from theafterEachbehind thevi.isFakeTimers()skip. So no new noise in the suite, and AGENTS.md's "suppress expected error output" rule isn't engaged. - Coverage-neutral.
src/test/**is outside theincludewhitelist (vite.config.ts:180-195—components/hooks/theme/lib/utils/server+core/*), so none of the new branches are gated.HEADER_ANIM_MS'sexportdoesn't changeViewHeader.tsx's coverage, andreactRefresh.configs.vite'sallowConstantExportkeeps it lint-clean.
1. The sequence.hooks comment asserts a Vitest default that I believe changed (worth a 30-second check)
vite.config.ts:293-294: "Run after-hooks LIFO (reverse registration) instead of Vitest's default "parallel"."
I'm fairly sure sequence.hooks defaulted to "parallel" only through Vitest 1.x/2.x and was changed to "stack" in Vitest 3 — this repo is on vitest ^4.1.0. If that's right, the pin isn't changing anything, and the comment's framing ("instead of the default") misdescribes what the line does: it would be documenting the default rather than overriding it, which is still a fine reason to keep it but a different one from what's written.
I can't confirm — this checkout has no node_modules — so treat this as a check, not a finding. You have the install: grep -rn "hooks" node_modules/vitest/dist/chunks/*.js around the config defaults, or just delete the line and see whether the same-suite afterEach(cleanup) probe behaves identically. Worth resolving because it's the one remaining comment in this PR asserting third-party behavior I couldn't verify, and the whole trail has been about not leaving claims the next reader will build on. Fix this →
2. Nit — the post-settle check is unreachable under the pin it sits next to
renderWithMantine.tsx:168-170 justifies the post-settle check as catching "a same-level 'parallel' race the pre-check can't see" — but with sequence.hooks: "stack" pinned on the unit project, same-level hooks run sequentially, so that race can't occur in this repo's configuration. It's correct as defensive code (the comment does frame it as guarding a future regression), and it costs nothing; just noting that the two additions land in slightly opposed positions — the pin removes the only mode in which the second check has a job. If finding 1 resolves to "the default is already stack", one clause covering both ("the post-check matters only if someone unpins this to parallel") would tie them together.
Verification caveat
This checkout has no node_modules, so I could not run the suite or confirm vi.isFakeTimers(), Vitest 4's sequence.hooks default, project-level sequence acceptance, or Vitest's cause rendering against installed versions. Finding 1 rests entirely on that gap — flagged as a check, not a claim. The hook-ordering mechanism rests on your rounds 5–7 probes (the "list" result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Everything in the confirmation section was verified against files on this branch.
Summary
Thirteen rounds in, and the last four have been narrowing to wording inside an error path that only fires if someone breaks the harness — which is the signal that the substance is finished. What ships: a helper-owned settle armed by the render that creates the hazard, a window derived from the animation it settles, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same corrected framing, liveness checks demonstrated to fire when broken, a drain that can neither be skipped by its own guard nor lose its explanatory error, a matchMedia override that no longer claims two contradictory preferences, and an AGENTS.md bullet that's a rule with the mechanism on the helper. Finding 1 is a 30-second check on a third-party default; the nit is optional. Ready to merge.
· v2/fix-1786-transition-timer-leak
…vitest 4, not "parallel") (round 14) Round-14 review of PR #1793 (merge-verdict): - Finding 1: verified against installed vitest 4.1.0 — `resolved.sequence.hooks ??= "stack"` (coverage chunk), so the runtime default is "stack"; the CLI help-text's "parallel" is stale in vitest itself. The vite.config.ts comment claimed the pin overrides a "parallel" default; corrected to say the pin is Vitest 4's own default made explicit (documents intent + guards a future default change), which is a truer reason to keep it than an override. - Nit: tied the two additions together — the post-settle check's same-level race can only arise if someone changes sequence.hooks to "parallel" (same-level hooks run sequentially under "stack"/"list"), so the pin and the post-check now reference each other rather than sitting in opposed positions. ViewHeader unchanged; npm run ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-14 handled in 661c963 — finding 1 was a good catch and you were right. Finding 1 (sequence.hooks default) — Verified against the installed vitest 4.1.0: Nit 2 — Tied the pin and the post-settle check together: the same-level race the post-check exists for can only arise if someone changes ViewHeader unchanged; |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 31s —— View job Review: PR #1793 — round 15 (
|
| Item | Status |
|---|---|
Finding 1 — sequence.hooks default claim |
Corrected (vite.config.ts:293-296). The comment now says the pin makes Vitest 4's existing "stack" default explicit (documenting intent + guarding a future default change) rather than overriding a "parallel" default, and cites the resolution site (resolved.sequence.hooks ??= "stack") plus the stale help-text as the source of the old belief. That's a truer reason to keep the line, and it doesn't disturb the mechanism conclusion — your probes flipped the mode explicitly, so the outer-cleanup finding stands independent of what the default is. I still can't verify against installed vitest (no node_modules here); accepting your quoted source line. |
| Nit 2 — pin and post-settle check sat in opposed positions | Tied together (renderWithMantine.tsx:169-172): the post-check's race is now scoped to "only if sequence.hooks is changed to parallel", with the "stack"/"list" sequential-hook fact stated. The two additions now reference each other. |
Re-verified end to end against the tree:
- Lifecycle intact.
resetArming()still precedes every early return and the drain (:178); empty liveness set →some()isfalse→ no throw, so a test that unmounted its only tree drains cleanly;settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - Consumers unchanged.
renderWithMantineTransitionsstill has exactly one consumer file, and all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:21,:243,:316,:389). NosettleTransitions()call sites remain. - Window still bounds the JS chain. All four header
Transitions useduration/exitDuration={HEADER_ANIM_MS}(ViewHeader.tsx:322/356/381/396) with noenterDelay/exitDelay, so300 + 200strictly exceeds one rAF pair + the terminal 300ms timeout.TOGGLE_ANIM_MS = 200(:439) is still unreachable under real timers (the tests passingmonitorToggleuserenderWithMantine) and is under 300 anyway. setup.tsand AGENTS.md are unchanged this round and still consistent: thematchMediablock is described as an override (not a polyfill), thereduce-anchored regex is in place, therespectReducedMotion/env="test"clarification stands, and the AGENTS.md bullet keeps the round-10 fake-timer wording ("it warns, but anything the test left pending on the real clock is then unprotected").- Coverage-neutral.
src/test/**is outside theincludewhitelist (vite.config.ts:180-195), and this commit adds no code anyway.
1. The auto-settle silently depends on isolate: true, which is nowhere stated (worth a line)
The settle is registered by a module-level afterEach in renderWithMantine.tsx:175. That hook attaches to whichever suite is collecting when the module first evaluates — i.e. the importing test file. It works today because nothing in the config sets isolate (I grepped clients/web/vite.config.ts, package.json, and vitest.shared.mts — no hits), so Vitest's default isolate: true gives every test file a fresh module registry and the hook re-registers per file.
Set isolate: false — a routine speed optimization someone will eventually try — and the module is evaluated once per worker. The afterEach then registers onto only the first file that imported it, and every other file's real-transitions renders arm armedSettleMs/liveArmedContainers with no hook to consume them. Consequences, in order of nastiness:
- The drain silently never runs for those files → test(web): flaky coverage gate — Mantine Transition timer leaks 'window is not defined' after teardown (#1760 class) #1786 is back, with no guard trip (the liveness checks live inside the hook that isn't registered).
- The module state leaks across files in that worker, so a stale container from file B can be consumed by a later test in file A and fail its
isConnectedcheck — a guard trip attributed to the wrong file.
Both are exactly the "green run is non-evidence / misattributed failure" shape this PR opened with, and this is the last load-bearing implicit config assumption left undocumented after rounds 5–7 nailed down the ordering one. Cheapest fix is a clause where the arming state is described (:105-115): "registration is per test-file module evaluation, so this assumes isolate: true (the default) — under isolate: false the hook would attach to only the first importing file." The structural alternative is arming via onTestFinished inside renderWithMantineTransitions (per-test, immune to module caching) — but that changes the ordering guarantee the file spent three rounds establishing, so I would not do it here; document the assumption instead. Fix this →
2. Nit — the new clause attributes the pre-check's blindness to the wrong cause
renderWithMantine.tsx:169-172: "…the post-settle check catches it detaching the tree concurrently while this hook awaits — a race that can only arise if the unit project's sequence.hooks is changed from "stack" to "parallel" … which is exactly why the pre-check can't see it."
The trailing clause doesn't follow. The pre-check can't see a concurrent detach because it runs synchronously before the await — that's a property of where it sits in the function, not of the hook mode. As written, a reader could conclude that flipping back to "stack" restores the pre-check's visibility. Also, "can only arise if sequence.hooks is parallel" is necessary-but-not-sufficient: it also needs cleanup moved to a same-level hook (stated earlier in the same sentence, so the compound is there — just easy to lose). Splitting it — "the pre-check runs before the await, so it can't observe a detach that happens mid-settle; that concurrent case additionally requires sequence.hooks: "parallel", since same-level hooks run sequentially under "stack"/"list"" — says both facts without the false attribution. Fix this →
Verification caveat
This checkout has no node_modules, so I could not run the suite or confirm Vitest 4's sequence.hooks default, vi.isFakeTimers(), project-level sequence acceptance, or cause rendering against installed versions. Round-14's central claim rests on your quoted resolved.sequence.hooks ??= "stack"; the hook-ordering mechanism rests on your rounds 5–7 probes (the "list" result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Finding 1 is derived from the absence of any isolate setting in the config files plus how module-level hooks bind at collection time — worth confirming with a isolate: false probe if you act on it beyond a comment.
Summary
Fourteen rounds in and the last five have been narrowing to wording inside an error path that only fires if someone breaks the harness — the substance has been settled since 5834cd24. What ships: a helper-owned settle armed by the render that creates the hazard, a window derived from the animation it settles, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same corrected framing, liveness checks demonstrated to fire when broken, a drain that can neither be skipped by its own guard nor lose its explanatory error, a matchMedia override that no longer claims two contradictory preferences, a sequence.hooks pin that now describes itself accurately, and an AGENTS.md bullet that's a rule with the mechanism on the helper. Finding 1 is the one I'd add a line for — it's the last implicit config dependency the file doesn't mention, and unlike the ordering one its failure mode is silent. The nit is optional.
· v2/fix-1786-transition-timer-leak
…d) + fix pre-check-blindness wording (round 15) Round-15 review of PR #1793 (merge-verdict; comment-only): - Finding 1: the auto-settle's module-level afterEach depends on isolate:true (the default) to re-register per test file; under isolate:false the module evaluates once per worker and the hook binds to only the first importing file, so other files arm with no consuming hook — the drain silently never runs and the state leaks across files. Reproduced it (vitest --no-isolate --pool=threads --no-file-parallelism over two real-transitions files leaves one file's arming unconsumed) and documented the assumption + the onTestFinished escape hatch on the arming-state comment. - Nit: the "sequence.hooks is why the pre-check can't see the concurrent detach" clause was wrong — the pre-check is blind because it runs synchronously before the await (a property of where it sits, not the hook mode). Reworded to state that, and that the concurrent case *additionally* needs sequence.hooks: "parallel". ViewHeader unchanged; npm run ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Round-15 handled in a99df04 — finding 1 was a real one, and I reproduced it before documenting. Finding 1 (implicit Nit 2 (pre-check blindness attributed to the wrong cause) — Reworded. The pre-check can't see a mid-settle detach because it runs synchronously before the await (a property of where it sits, not the hook mode); the concurrent case additionally needs ViewHeader unchanged; |
|
@claude review |
|
Claude finished @cliffhall's task in 3m 7s —— View job Review: PR #1793 — round 16 (
|
| Item | Status |
|---|---|
Finding 1 — undocumented isolate: true dependency |
Documented (renderWithMantine.tsx:113-130), and better than I asked for: it states the assumption, the isolate: false failure mode (hook binds to the first importing file → other files arm with nothing to consume them → drain silently never runs, plus cross-file state leakage), the probe that reproduces it (vitest --no-isolate --pool=threads --no-file-parallelism over two real-transitions files), and the structural escape (onTestFinished) with the caveat that its ordering vs cleanup() would need re-establishing. Recording the repro is what makes this a fact rather than a caution. I re-grepped clients/web/vite.config.ts, vitest.shared.mts, and clients/web/package.json for isolate / pool / fileParallelism / poolOptions — zero hits, so "nothing overrides it" is accurate. |
| Nit 2 — pre-check blindness attributed to the hook mode | Reworded (:184-189). The two facts are now separated correctly: the pre-check can't see a mid-settle detach because it runs synchronously before the await (structural), and the concurrent case additionally requires sequence.hooks: "parallel" (same-level hooks run sequentially under "stack"/"list"). The false "which is exactly why the pre-check can't see it" is gone. |
Re-verified against the tree
- Consumers:
renderWithMantineTransitionsstill has exactly one consumer file, and all three call sites passsettleMs: TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200(ViewHeader.test.tsx:21,:243,:316,:389). NosettleTransitions()call sites. Of the 121 files importing the helper, the only two non-*.test.tsxhits are the helper itself and a comment insetup.ts:69— so the module-levelafterEachand thevitestimport can't reach the Storybook browser project, the node-envintegrationproject (zero importers undersrc/test/integration), or avite build. - Lifecycle:
resetArming()still precedes every early return and the drain (:195), so nothing leaks between tests even if the settle throws. Empty liveness set →some()isfalse→ no throw.settleMs: 0still reaches theactflush (Math.max(null ?? 0, 0) === 0;ms === nullfalse). - Window still bounds the JS chain:
HEADER_ANIM_MS = 300is theduration/exitDurationon all four headerTransitions with noenterDelay/exitDelay, so300 + 200strictly exceeds one rAF pair + the terminal timeout. - Fake-timer tests can't arm:
:277and:419both render viarenderWithMantineand restore in afinally(:307,:447), so a failure inside one can't leak a fake clock into a settling test. sequenceplacement: inside the unit project'stestobject (vite.config.ts:306), not the root — so the pin is real, and its comment now correctly describes it as making Vitest 4's existing"stack"default explicit rather than overriding a"parallel"one.- Coverage-neutral:
src/test/**is outside theincludewhitelist (vite.config.ts:180-195), so none of this is gated — and this commit adds no code anyway.
The new candidate I chased, and why it's a non-issue
ViewHeader.test.tsx:25-32 mocks @mantine/hooks and replaces useMediaQuery wholesale — and the three real-transitions tests set mediaQueryMock.value = true. Since Mantine's useReducedMotion is useMediaQuery('(prefers-reduced-motion: reduce)'), that looked like a second, file-local path to "reduced motion is on" in exactly the tests that depend on real timers — one nobody had noted, and independent of the setup.ts matchMedia override this PR spent rounds correcting.
It's inert, for the reason this PR itself established: useTransition computes reduceMotion = theme.respectReducedMotion ? shouldReduceMotion : false, Mantine 8 defaults respectReducedMotion to false, and src/theme/theme.ts doesn't override it — so no reduced-motion signal from either path can collapse the duration. The mock also uses importActual + spread, so useReducedMotion itself is real. Worth recording that this was checked: it means "don't flip respectReducedMotion" (round 1's advice) is load-bearing for two independent reasons in this file, not one.
Nit (genuinely not worth a round)
renderWithMantine.tsx:189-191 carries a line-wrap seam from this commit's edit — "…Neither fires in the current outer-cleanup / setup. A container the test unmounted itself isn't in / the set, so…" — two short lines mid-paragraph. Prettier doesn't reflow comments, so format:check passes and nothing is wrong; it just reads as an editing artifact. Fold it into the next comment change in this file, if there ever is one.
Verification caveat
This checkout has no node_modules (root and clients/web both absent), so I could not run the suite or read Vitest 4 / Mantine 8 at source. Specifically unverified here: resolved.sequence.hooks ??= "stack" (round 14), vi.isFakeTimers(), project-level sequence acceptance, and the isolate: false probe result — all rest on your local runs, and the isolate one is now the strongest of them because you recorded the exact command. The hook-ordering mechanism rests on your rounds 5–7 probes (the "list" result and the same-suite afterEach(cleanup) probe remain the discriminating evidence). Everything in the confirmation sections above was verified against files on this branch. CI run 30215167004 is in progress as I write this — worth confirming green before merge, since I couldn't run anything locally.
Summary
The change does what #1786 asked and then some: a helper-owned settle armed by the render that creates the hazard, a window derived from the animation it settles, per-container arming so multi-render and self-unmount compose, both fake-timer paths signalling the same corrected framing, liveness checks demonstrated to fire when broken, a drain that can neither be skipped by its own guard nor lose its explanatory error, a matchMedia override that no longer claims two contradictory preferences, a sequence.hooks pin that describes itself accurately, a documented (and reproduced) isolate: true dependency, and an AGENTS.md bullet that's a rule with the mechanism on the helper. Nothing left that I'd act on. Ship it once CI is green.
· v2/fix-1786-transition-timer-leak
|
Thanks — agreed, stopping the review loop here. Round-16 returned a merge verdict with no findings (the Confirming the thing you couldn't check locally: GitHub CI is green on the head commit ( Fifteen rounds of review turned a three-line flaky-gate fix into properly-owned test infrastructure — appreciate the depth. Ready to merge. |
Local smoke test resultsBranch
Caveat on the evidence: this failure class is timer-timing dependent, so green runs are weaker evidence than red ones (as the PR description itself notes). Three clean full-suite passes plus one clean instrumented coverage run is a reasonable signal, not proof. Not re-run in this pass: 🤖 Generated with Claude Code |
Closes #1786
Problem
The web coverage gate was intermittently failing with an unhandled
ReferenceError: window is not definedthrown from a MantineTransitiontimer that fired after happy-dom tore downwindow— the #1760 failure class AGENTS.md documents. Because it's timer-timing dependent, a green run was non-evidence: it failed a docs-only commit on PR #1784 while all 4470 tests passed.Root cause
ViewHeader.test.tsxhas three tests that opt into real (env="default") Mantine transitions viarenderWithMantineTransitionsto observe mid-flightdata-anim="in"/"out"cells. Each drove its primary transition to completion (waitForthe exiting cell to unmount), but the concurrent enter on the sibling crossfade cell leaves no unmount towaitFor— a completed enter has no DOM signal. So itsrAF → setTimeout(300ms)chain could still be pending at test end; when it fired after teardown, react-dom'sdispatchSetState → resolveUpdatePriorityread a torn-downwindowand threw, failing whatever unrelated file was running at that instant.Fix
settleTransitions()at the end of each of the three transitions tests: anact-wrapped wait (500ms > the 300ms header duration + two-frame rAF slack) that drains every pending transition timer against the still-mounted tree, so the settlingsetStatetargets a live component and nothing remains to fire after cleanup unmounts. Deterministic — the wait strictly exceeds the JS timer chain (the stagger is CSS-only; no MantineenterDelay/exitDelay).colorSchemeoption onrenderWithMantine/renderWithMantineTransitions(closes the related nit):ViewHeader.test.tsxno longer hand-rolls a baredefaultColorScheme="dark"MantineProvider — the Flaky test teardown: Mantine Transition timer fires after happy-dom window teardown (window is not defined) #1760 anti-pattern the AGENTS.md rule forbids. It now callsrenderWithMantine(ui, { colorScheme: "dark" }).Verification
ViewHeader.test.tsx(20 tests) run repeatedly — clean every time, nowindow is not defined.npm run cigreen end-to-end locally:validate→ the per-file ≥90coveragegate →verify:build-gate→smoke→ 460 Storybook tests.Acceptance
window is not defined; repeated runs clean.renderWithMantineTransitionstests drive transitions to completion / settle before teardown.ViewHeader.test.tsxno longer hand-rolls a bareMantineProvider(helper gains acolorSchemeoption).npm run cigreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5