facebook / react · Issue No. 37100
The DEV build of react-dom reads window.event unconditionally at the entry of Scheduler-driven tasks (Performance Tracks event attribution, added in 19.2.0).
Scheduler tasks live on the host event loop (setImmediate in Node, captured at module load) and can legitimately fire after a jsdom / happy-dom test environment has been torn down. When that happens, the task throws ReferenceError: window is not defined before doing any work — in per-test-file DOM environments (vitest, jest) this fails entire CI suites with an unhandled error while every test passes.
Tested against the latest packages: still present in react-dom 19.2.8 (latest), 19.3.0-canary-711c445b-20260722, and on current main.
React version: 19.2.0 – 19.2.8 (regression introduced in 19.2.0; 19.1.x is not affected)
npm i react@19.2.8 react-dom@19.2.8 jsdomrepro.mjs and run node repro.mjs (verified on Node v24.10.0)Link to code example:
Minimal script inlined below — the crash is deterministic (no test runner, no timing dependence). The only dependency besides React is jsdom, which is the subject of the bug: it emulates the per-test-file DOM environment that runners like vitest and jest create and tear down.
// repro.mjs
import { JSDOM } from "jsdom";
import React from "react";
const dom = new JSDOM('<div id="root"></div>');
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const { createRoot } = await import("react-dom/client");
const root = createRoot(document.getElementById("root"));
// Concurrent render: the actual work is scheduled on the Scheduler,
// which uses setImmediate in Node.
root.render(React.createElement("div", null, "hello"));
// Simulate what vitest / jest do between test files: tear down the DOM
// environment while the scheduled task is still queued.
delete globalThis.window;
delete globalThis.document;
setTimeout(() => console.log("done, no crash"), 50);
The script crashes:
react-dom/cjs/react-dom-client.development.js:18936
schedulerEvent = window.event;
^
ReferenceError: window is not defined
at performWorkOnRootViaSchedulerTask (react-dom-client.development.js:18936:7)
at Immediate.performWorkUntilDeadline (scheduler/cjs/scheduler.development.js:45:48)
at process.processImmediate (node:internal/timers:505:21)
(Removing the two delete lines makes it print done, no crash and exit 0.)
The unguarded reads are trackSchedulerEvent() / resolveEventType() / resolveEventTimeStamp() in ReactFiberConfigDOM.js (L806-L818).trackSchedulerEvent() runs at the top of performWorkOnRootViaSchedulerTask and processRootScheduleInImmediateTask, and of the passive-effects flush callback scheduled by commitRoot — i.e. at the entry of deferred tasks.
This is a regression introduced in 19.2.0. Comparing the published DEV builds:
| react-dom (DEV) | window.event reads |
where they sit |
|---|---|---|
| 19.0.0 / 19.1.x | 2 | resolveUpdatePriority() / shouldAttemptEagerTransition() — only called while an update is being dispatched, i.e. window is alive by construction |
| 19.2.0+ | 7 | adds trackSchedulerEvent() at the entry of Scheduler tasks, which run on deferred setImmediate ticks and can fire after the environment that scheduled them is gone |
Real-world impact: in test runners that create one DOM environment per test file, React work scheduled near the end of a file (e.g. a passive-effects flush from a commit outside act(), routinely triggered by library internals without any test bug) can survive into the "no window" gap between two files. On slow CI runners this fails whole suites with "Vitest caught 1 unhandled error during the test run" and a non-zero exit code while every test passes, and the error is attributed to the next, innocent test file — which makes it look like machine-speed-dependent flakiness and very hard to diagnose. Since the read is DEV-only, production builds are unaffected — but the DEV build is exactly what test environments run.
Best-effort profiling instrumentation should not be able to crash the process.
When window is not available, the attribution can resolve to undefined:
export function trackSchedulerEvent(): void {
schedulerEvent = typeof window !== 'undefined' ? window.event : undefined;
}
This would match react-dom's existing convention: the same bundle already typeof-guards other window accesses that may run outside a browser DOM (e.g. the canUseDOM check and the window.ErrorEvent detection in the error-reporting path).
The 19.2.0 instrumentation reads are the only unguarded window accesses sitting on a deferred-task path.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.