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);
+}