test(ui): drain React's scheduler before jsdom teardown (BLO-23426) - #2022
Conversation
React 19 schedules a passive-effect flush after every commit with passive flags -- including the flushSync unmount most ui tests do in afterEach -- and that scheduler task reads `window.event` before anything else (react-dom-client.development.js:17920). When a jsdom file's environment is torn down before the setImmediate fires, it throws "window is not defined" as an unhandled error, and vitest exits 1 on a fully green run. It has ejected merge-queue groups with every test passing: #1987 (35903530339, 20 errors, BuiltInBundlePanel) and #1976 (35993984182, 4 errors, ArtifactGroupCard). The blamed file changes run to run because any component can leave the task behind, so the fix is teardown ordering in the one place every ui file shares: a setup-file afterAll, which vitest runs last (after-hooks are stack-ordered), drains a few macrotasks while jsdom is still installed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
✅ All checks passing — ready for Greptile review and maintainer approval. — commitperclip |
… no-op commitperclip rightly asked for a test. The drain now lives in ui/src/lib/drainReactScheduler.ts, which vitest.setup.ts registers as the jsdom afterAll. The new test flushSync-renders and unmounts a component with a useEffect, drains, then requires that three more immediate turns produce no further `window.event` reads. A read on those turns is exactly what throws "window is not defined" after a real teardown. It fails with the drain made a no-op (expected 6 to be 4) and still passes if React ever stops deferring the task. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@allyblockcast please publish one consolidated review for exact current head It's a teardown-ordering fix for BLO-23426: a jsdom 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 50e4882
Looks good. A teardown-ordering fix that treats the cause rather than the blamed file, with a regression test that is mutation-checked rather than assumed. Nothing blocking.
Critical Issues (0)
Important Issues (0)
Suggestions (4)
- [native-codex]
ui/vitest.setup.ts:51— Vitest's defaultfakeTimers.toFakeincludessetImmediate, so a jsdom file that installs fake timers and never restores them would leave this hook awaiting an immediate that never fires, hanging to the 60shookTimeoutinstead of failing fast. Not live today — the full ui suite is green at this head, and the reverse-registration ordering you rely on also means any file-levelafterEach/afterAllcallingvi.useRealTimers()runs before this hook, so the correct patterns are already protected. Only a file with no restore at all is exposed. Worth a defensivevi.useRealTimers()at the top of the drain, or a line in the comment noting the assumption, so the next person meets a clear cause rather than a 60s timeout. - [code]
ui/src/lib/drainReactScheduler.ts:12—setImmediateis Node-only, but this module sits inui/src/, the browser source tree thatvite buildcompiles. Nothing in app code imports it so it tree-shakes out today, and the placement does match the repo's colocated-test convention. The risk is purely that the location invites a future app-side import, wheresetImmediateis undefined. A one-line "test-environment only, NodesetImmediate" note on the export would close it without moving the file. - [code]
ui/src/lib/drainReactScheduler.ts:12— the turn count is a bounded heuristic: three covers a task that re-posts itself once, and a hypothetical deeper re-post chain would slip through. The docblock already says as much, which is the right call over a drain-until-idle loop that could spin. Noting it only so the ceiling is on the record — if this flake ever recurs, the turn count is the first knob, not evidence the approach is wrong. - [tests]
ui/src/lib/drainReactScheduler.test.tsx:31— the assertion isreads == afterDrain, so if a future React version stops deferring the passive flush,afterDrainis whatever it is and the test passes vacuously while silently no longer guarding anything. Your PR body calls this out deliberately, and the tradeoff is defensible — assertingafterDrain > 0would couple the test to React internals and buy brittleness. Flagging only so the known ceiling of the guard is recorded alongside the guard.
Strengths
- Fixes the mechanism, not the blamed file.
ArtifactGroupCard/BuiltInBundlePanelwere symptoms of whichever file happened to leave the task behind; a setup-file drain covers all of them, which is why this should stop recurring under a new name. - The mutation check is the part that makes this reviewable. "No-op the drain and the new test fails with
expected 6 to be 4" is evidence the test would notice the fix being removed — as opposed to a test that merely passes alongside it. - The ordering claim is verified against
@vitest/runner4.1.8 rather than assumed, and I confirmed it independently:sequence.hooksis unset inui/vitest.config.ts, so the defaultstackapplies and this hook runs after every test file's own hooks. - Comments carry the two merge-group run IDs and the reverse-registration assumption. That is exactly what a future reader needs to tell a real regression from a re-tune, and it is rare.
- The
typeof window !== "undefined"guard keeps node-environment files untouched, and the cost on jsdom files is three immediate turns. - Scope discipline: BLO-31595/BLO-31438 are named as a different mechanism this explicitly does not address, rather than being folded in.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Thinking Path
Linked Issues or Issue Description
setTimeouts that fire after teardown: a different mechanism, which this does not address.What Changed
ui/src/lib/drainReactScheduler.ts(new):drainReactScheduler()awaits threesetImmediateturns. Node runs immediates in order, so this runs every scheduler task React queued before the call.ui/src/lib/drainReactScheduler.test.tsx(new): after aflushSyncrender and unmount of a component with auseEffect, followed by the drain, three more immediate turns must produce no furtherwindow.eventreads. If React stops deferring the task the test still passes; it fails only when the drain leaves work behind.ui/vitest.setup.ts: for jsdom files only (typeof window !== "undefined"),afterAll(drainReactScheduler), with a comment citing the two run IDs.Verification
origin/masterwith a throwaway probe test. AfterflushSync(() => root.unmount())of a component with auseEffect, awindow.eventgetter has not yet been read; oneawait new Promise(r => setImmediate(r))later, it has. So a pending task that toucheswindowexists at unmount, and one immediate turn runs it.@vitest/runner4.1.8:afterAllhooks run in reverse registration order (sequence.hooksdefaultstack, not overridden inui/vitest.config.tsor the root config). The setup-file hook is registered first, so it runs after every test file's ownafterEach/afterAll, and before environment teardown.drainReactSchedulera no-op fails the new test withexpected 6 to be 4, because two pendingwindow.eventreads survive the no-op. The file was restored byte-identical afterwards.cd ui && npx vitest runat50e488241: 373/374 files and 3073 tests passed, with 0 unhandled errors. The one red file wasApp.cases-routing.test.tsx: itsbeforeAllhit its own 180s App-import timeout (BLO-17053) while the devbox sat at load average 67 on 32 cores. Re-run alone it passes (4/4 together with the new test). An earlier full run at629150f0awas 373/373 and 3075/3075, exit 0.cd ui && npx tsc --noEmit -p .: exit 0.ArtifactGroupCard,BuiltInBundlePanel,routine-sections/editable-sections) pass together, 16/16.Risks
setImmediateturns per jsdom test file, which is microseconds. Node-environment files are unaffected because the hook is only registered whenwindowexists.windowdefined.Model Used
claude-opus-5-5, 1M context) via Claude Code, with tool use (shell, file edit, test execution) and extended thinking.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code