Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ui/src/lib/drainReactScheduler.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WithEffect />));
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);
});
13 changes: 13 additions & 0 deletions ui/src/lib/drainReactScheduler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
for (let i = 0; i < 3; i++) await new Promise((resolve) => setImmediate(resolve));
}
12 changes: 12 additions & 0 deletions ui/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { afterAll } from "vitest";
import { drainReactScheduler } from "./src/lib/drainReactScheduler";

const storageEntries = new Map<string, string>();

function installStorageMock(target: Record<string, unknown>) {
Expand Down Expand Up @@ -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);
}
Loading