diff --git a/ui/src/lib/drainReactScheduler.test.tsx b/ui/src/lib/drainReactScheduler.test.tsx new file mode 100644 index 000000000000..caf6a2ee6ab8 --- /dev/null +++ b/ui/src/lib/drainReactScheduler.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom + +import { useEffect } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it } from "vitest"; +import { drainReactScheduler } from "./drainReactScheduler"; + +afterEach(() => { + delete (window as { event?: unknown }).event; +}); + +it("leaves no scheduler task that reads window pending after an unmount", async () => { + let reads = 0; + Object.defineProperty(window, "event", { + configurable: true, + get: () => { + reads++; + return undefined; + }, + }); + function WithEffect() { + useEffect(() => () => {}, []); + return null; + } + const root = createRoot(document.createElement("div")); + flushSync(() => root.render()); + flushSync(() => root.unmount()); + + await drainReactScheduler(); + const afterDrain = reads; + // Anything the drain left queued runs on these turns and reads window.event + // again -- after a real teardown, that read is the "window is not defined". + for (let i = 0; i < 3; i++) await new Promise((resolve) => setImmediate(resolve)); + expect(reads).toBe(afterDrain); +}); diff --git a/ui/src/lib/drainReactScheduler.ts b/ui/src/lib/drainReactScheduler.ts new file mode 100644 index 000000000000..039b64c8ac08 --- /dev/null +++ b/ui/src/lib/drainReactScheduler.ts @@ -0,0 +1,13 @@ +/** + * Runs the work React has already queued on its scheduler, while the test + * environment still exists. + * + * React 19 schedules a passive-effect flush after every commit with passive + * flags (a flushSync unmount included), and that task reads `window.event` + * before anything else. In Node the scheduler posts it with setImmediate, and + * immediates run in order, so awaiting our own turns runs everything queued + * before the call. Three turns cover a task that yields and re-posts itself. + */ +export async function drainReactScheduler(): Promise { + for (let i = 0; i < 3; i++) await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts index 12d640ac069f..e3146fee633a 100644 --- a/ui/vitest.setup.ts +++ b/ui/vitest.setup.ts @@ -1,3 +1,6 @@ +import { afterAll } from "vitest"; +import { drainReactScheduler } from "./src/lib/drainReactScheduler"; + const storageEntries = new Map(); function installStorageMock(target: Record) { @@ -38,3 +41,12 @@ if (typeof window !== "undefined" && window.localStorage !== globalThis.localSto if (typeof Element !== "undefined" && typeof Element.prototype.scrollIntoView !== "function") { Element.prototype.scrollIntoView = function scrollIntoView() {}; } + +// A scheduler task React left behind (see drainReactScheduler) that fires after +// jsdom is torn down throws "window is not defined" as an unhandled error and +// turns an all-green workspaces-a run red (BLO-23426; merge groups 35903530339, +// 35993984182). After-hooks run in reverse registration order, so this one runs +// after every test file's own hooks and before the environment goes away. +if (typeof window !== "undefined") { + afterAll(drainReactScheduler); +}