diff --git a/deno.json b/deno.json index 4e8887e505..8cdc8d79c1 100644 --- a/deno.json +++ b/deno.json @@ -478,9 +478,9 @@ "storybook": "npm --prefix storybook run storybook", "build:storybook": "npm --prefix storybook run build-storybook", "storybook:check": "deno test --no-lock --config=scripts/test.deno.json --no-check --allow-read scripts/storybook/storybook-workbench.test.ts", - "lint": "DENO_NO_PACKAGE_JSON=1 deno lint src/ cli/ react/ && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", - "fmt": "deno fmt src/ cli/ react/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", - "fmt:check": "deno fmt --check src/ cli/ react/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", + "lint": "DENO_NO_PACKAGE_JSON=1 deno lint src/ cli/ react/ && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/prepare-framework-sources.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", + "fmt": "deno fmt src/ cli/ react/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", + "fmt:check": "deno fmt --check src/ cli/ react/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", "verify": "deno task generate:manifests:check && deno task fmt:check && deno task lint && deno task lint:style && deno task lint:chat-composability && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:core-deps && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:validate && deno task typecheck && deno task typecheck:consumer && deno task test && deno task test:e2e:binary", "verify:quick": "deno task generate:manifests:check && deno task fmt:check && deno task lint && deno task lint:style && deno task lint:chat-composability && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:core-deps && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:validate && deno task typecheck", @@ -519,7 +519,7 @@ "lint:ban-test-only": "deno run --allow-read scripts/lint/ban-test-only.ts", "lint:sanitizer-baseline": "deno run --allow-read scripts/lint/check-sanitizer-baseline.ts", "lint:skipped-tests": "deno run --allow-read scripts/lint/check-skipped-tests-baseline.ts", - "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live", + "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/prepare-framework-sources.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live", "test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts", "test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts", "test:cross-runtime": "deno run --allow-all src/platform/compat/cross-runtime.test.ts", diff --git a/scripts/build/prepare-framework-sources.test.ts b/scripts/build/prepare-framework-sources.test.ts new file mode 100644 index 0000000000..c0c8fb1ea9 --- /dev/null +++ b/scripts/build/prepare-framework-sources.test.ts @@ -0,0 +1,61 @@ +import { assertEquals } from "#std/assert"; +import { join } from "#std/path.ts"; +import { describe, it } from "#std/testing/bdd"; +import { prepareFrameworkSources } from "./prepare-framework-sources.ts"; + +describe("prepareFrameworkSources", () => { + it("excludes tests and test helpers from binary framework sources", async () => { + const temporaryRoot = await Deno.makeTempDir(); + const sourceRoot = join(temporaryRoot, "src"); + const outputRoot = join(temporaryRoot, "dist"); + + try { + await Deno.mkdir(sourceRoot, { recursive: true }); + await Promise.all([ + Deno.writeTextFile( + join(sourceRoot, "runtime.ts"), + "export const runtime = true;\n", + ), + Deno.writeTextFile( + join(sourceRoot, "runtime.test.ts"), + "throw new Error('test');\n", + ), + Deno.writeTextFile( + join(sourceRoot, "react-root.test-helpers.ts"), + "throw new Error('test helper');\n", + ), + ]); + + const result = await prepareFrameworkSources({ + srcRoot: sourceRoot, + outputDir: outputRoot, + }); + + assertEquals(result.fileCount, 1); + assertEquals( + await Deno.readTextFile(join(outputRoot, "runtime.ts.src")), + "export const runtime = true;\n", + ); + assertEquals( + await exists(join(outputRoot, "runtime.test.ts.src")), + false, + ); + assertEquals( + await exists(join(outputRoot, "react-root.test-helpers.ts.src")), + false, + ); + } finally { + await Deno.remove(temporaryRoot, { recursive: true }); + } + }); +}); + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} diff --git a/scripts/build/prepare-framework-sources.ts b/scripts/build/prepare-framework-sources.ts index 7a9fc9b54e..07f18f70a4 100644 --- a/scripts/build/prepare-framework-sources.ts +++ b/scripts/build/prepare-framework-sources.ts @@ -16,7 +16,7 @@ * This is run automatically before `deno compile` in production builds. */ -import { walk } from "@std/fs"; +import { walk } from "#std/fs"; import { dirname, fromFileUrl, join, relative } from "#std/path.ts"; const FRAMEWORK_ROOT = fromFileUrl(new URL("../..", import.meta.url)); @@ -54,10 +54,12 @@ export async function prepareFrameworkSources( let totalBytes = 0; const encoder = new TextEncoder(); - for await (const entry of walk(srcRoot, { - exts: SOURCE_EXTENSIONS.map((extension) => extension.slice(1)), - includeDirs: false, - })) { + for await ( + const entry of walk(srcRoot, { + exts: SOURCE_EXTENSIONS.map((extension) => extension.slice(1)), + includeDirs: false, + }) + ) { const relativePath = relative(srcRoot, entry.path); const normalizedPath = relativePath.replaceAll("\\", "/"); if ( @@ -90,7 +92,11 @@ async function main(): Promise { const { fileCount, totalBytes } = await prepareFrameworkSources(); - console.log(`[prepare-framework-sources] Complete: ${fileCount} files, ${(totalBytes / 1024).toFixed(1)} KB`); + console.log( + `[prepare-framework-sources] Complete: ${fileCount} files, ${ + (totalBytes / 1024).toFixed(1) + } KB`, + ); } if (import.meta.main) await main(); diff --git a/src/react/components/chat/agent-picker.test.tsx b/src/react/components/chat/agent-picker.test.tsx index 177e63c235..4ef855504e 100644 --- a/src/react/components/chat/agent-picker.test.tsx +++ b/src/react/components/chat/agent-picker.test.tsx @@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import type { AgentOption } from "./agent-picker.tsx"; @@ -111,7 +112,7 @@ describe("AgentPicker — preset (back-compat)", () => { assertEquals(loading.getAttribute("aria-selected"), "false"); assertEquals(loading.querySelectorAll('[aria-hidden="true"]').length, 3); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { dom.restore(); @@ -228,7 +229,7 @@ describe("AgentPicker — composability contract", () => { assertEquals(created, 1); assertEquals(managed, 0); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { dom.restore(); diff --git a/src/react/components/chat/chat-agent-picker.test.tsx b/src/react/components/chat/chat-agent-picker.test.tsx index 2622492576..5a135167cc 100644 --- a/src/react/components/chat/chat-agent-picker.test.tsx +++ b/src/react/components/chat/chat-agent-picker.test.tsx @@ -1,6 +1,7 @@ import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { agentsToPickerOptions, ChatAgentPicker } from "./chat-agent-picker.tsx"; @@ -109,7 +110,7 @@ describe("react/components/chat/chat-agent-picker: connected", () => { assert(trigger, "picker trigger renders after the agents resolve"); assertStringIncludes(trigger.textContent ?? "", "Select agent"); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreFetch(); @@ -134,7 +135,7 @@ describe("react/components/chat/chat-agent-picker: connected", () => { assert(trigger, "picker trigger renders"); assertStringIncludes(trigger.textContent ?? "", "Sales Agent"); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreFetch(); @@ -155,7 +156,7 @@ describe("react/components/chat/chat-agent-picker: connected", () => { assertEquals(rootElement.querySelector("button"), null); assertEquals(rootElement.textContent, ""); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreFetch(); @@ -181,7 +182,7 @@ describe("react/components/chat/chat-agent-picker: connected", () => { assertEquals(fetches, 0); assertEquals(rootElement.querySelector("button"), null); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { globalThis.fetch = previousFetch; diff --git a/src/react/components/chat/chat/chat.controlled.test.tsx b/src/react/components/chat/chat/chat.controlled.test.tsx index 6bcbe85f5f..386df93032 100644 --- a/src/react/components/chat/chat/chat.controlled.test.tsx +++ b/src/react/components/chat/chat/chat.controlled.test.tsx @@ -6,6 +6,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import type { ChatMessage, UseChatResult } from "#veryfront/agent/react"; @@ -117,7 +118,7 @@ describe("Chat — controlled via chat={useChat()}", () => { ); }); - it("submits externally controlled attachments through the chat session", () => { + it("submits externally controlled attachments through the chat session", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -171,7 +172,7 @@ describe("Chat — controlled via chat={useChat()}", () => { }], }); assertEquals(removed, ["file-1"]); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } diff --git a/src/react/components/chat/chat/components/code-block.test.tsx b/src/react/components/chat/chat/components/code-block.test.tsx index ee1ba87bb1..d4eae00d64 100644 --- a/src/react/components/chat/chat/components/code-block.test.tsx +++ b/src/react/components/chat/chat/components/code-block.test.tsx @@ -2,6 +2,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import { RichCodeBlock } from "./code-block.tsx"; @@ -48,12 +49,20 @@ async function settle(): Promise { flushSync(() => {}); } -// Unmounting leaves a scheduler callback queued; drain it so the leak -// sanitizer does not attribute that timer to the test. -async function unmount(root: Root | undefined): Promise { - if (!root) return; - flushSync(() => root.unmount()); - await new Promise((resolve) => setTimeout(resolve, 0)); +/** + * Poll for a state transition instead of assuming `settle()` covered it. + * + * A copy result travels through a promise chain and then a React commit, and a + * single macrotask does not always cover both under load. + */ +async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error("Timed out waiting for code block state"); + } + await settle(); + } } describe("RichCodeBlock — inline mode", () => { @@ -133,7 +142,7 @@ describe("RichCodeBlock — block mode", () => { ); assertEquals(document.querySelectorAll("textarea").length, 0); } finally { - await unmount(root); + await unmountReactRoot(root); dom.restore(); } }); @@ -170,8 +179,7 @@ describe("RichCodeBlock — block mode", () => { assertEquals(pending.length, 2); pending[1]?.(); - await settle(); - assertEquals(secondButton.textContent?.trim(), "Copied"); + await waitFor(() => secondButton.textContent?.trim() === "Copied"); pending[0]?.(); await settle(); @@ -179,7 +187,7 @@ describe("RichCodeBlock — block mode", () => { assertStringIncludes(rootElement.textContent ?? "", "new code"); assert(!(rootElement.textContent ?? "").includes("old code")); } finally { - await unmount(root); + await unmountReactRoot(root); dom.restore(); } }); diff --git a/src/react/components/chat/chat/components/inline-citation.test.tsx b/src/react/components/chat/chat/components/inline-citation.test.tsx index 3aeefb6715..7b21dfc36f 100644 --- a/src/react/components/chat/chat/components/inline-citation.test.tsx +++ b/src/react/components/chat/chat/components/inline-citation.test.tsx @@ -1,6 +1,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { waitFor } from "#veryfront/testing/deno-compat.ts"; @@ -84,7 +85,7 @@ describe("InlineCitation", () => { assert(card, "Expected citation card to render after hover"); assertEquals(card.textContent, "Custom citation card"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } diff --git a/src/react/components/chat/chat/components/message-actions.test.tsx b/src/react/components/chat/chat/components/message-actions.test.tsx index 1d1e807948..2fcc3171ab 100644 --- a/src/react/components/chat/chat/components/message-actions.test.tsx +++ b/src/react/components/chat/chat/components/message-actions.test.tsx @@ -2,6 +2,7 @@ import { createRoot, type Root } from "react-dom/client"; import { flushSync } from "react-dom"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MessageActionBar } from "./message-actions.tsx"; @@ -42,18 +43,20 @@ function installDom(): { restore: () => void; window: JSDOM["window"] } { }; } -/** - * Unmount and drain the one-shot tasks the runtime leaves behind. - * - * The `execCommand` copy fallback selects a textarea, and jsdom queues that - * `select` event on a bare `setTimeout` it never registers with the window, so - * `window.close()` cannot clear it. React's scheduler likewise holds a - * `setImmediate` until it next runs. Both complete on their own, but the test - * has to yield once more or Deno's leak sanitizer sees them still pending. - */ -async function unmount(root: Root): Promise { - flushSync(() => root.unmount()); +async function settle(): Promise { + await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + +async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error("Timed out waiting for message action state"); + } + await settle(); + } } describe("MessageActionBar", () => { @@ -103,6 +106,7 @@ describe("MessageActionBar", () => { it("renders the composed copied-state leaf after copying", async () => { const dom = installDom(); + let root: Root | undefined; const writes: string[] = []; Object.defineProperty(dom.window.navigator, "clipboard", { configurable: true, @@ -116,9 +120,10 @@ describe("MessageActionBar", () => { try { const rootElement = document.getElementById("root"); assert(rootElement, "root element exists"); - const root = createRoot(rootElement); + const createdRoot = createRoot(rootElement); + root = createdRoot; flushSync(() => { - root.render( + createdRoot.render( copy} /> { ); assert(copy, "copy action renders"); flushSync(() => copy.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }))); - // Let the clipboard promise settle without outwaiting the transient copied state. - await new Promise((resolve) => setTimeout(resolve, 0)); - flushSync(() => {}); + await waitFor(() => rootElement.querySelector('[data-testid="custom-copied"]') !== null); assertEquals(writes, ["Answer"]); assert(rootElement.querySelector('[data-testid="custom-copied"]')); assertStringIncludes(rootElement.innerHTML, "vf-copied"); flushSync(() => { - root.render( + createdRoot.render( copy} /> copied} /> @@ -152,15 +155,15 @@ describe("MessageActionBar", () => { }); assert(rootElement.querySelector('[data-testid="custom-copy"]')); assert(!rootElement.querySelector('[data-testid="custom-copied"]')); - - await unmount(root); } finally { + if (root) await unmountReactRoot(root); dom.restore(); } }); it("keeps the copy action available when every clipboard mechanism fails", async () => { const dom = installDom(); + let root: Root | undefined; Object.defineProperty(dom.window.navigator, "clipboard", { configurable: true, value: { writeText: () => Promise.reject(new Error("denied")) }, @@ -173,9 +176,10 @@ describe("MessageActionBar", () => { try { const rootElement = document.getElementById("root"); assert(rootElement, "root element exists"); - const root = createRoot(rootElement); + const createdRoot = createRoot(rootElement); + root = createdRoot; flushSync(() => { - root.render( + createdRoot.render( copy} /> copied} /> @@ -188,8 +192,7 @@ describe("MessageActionBar", () => { ); assert(copy, "copy action renders"); copy.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 0)); - flushSync(() => {}); + await waitFor(() => copy.getAttribute("aria-label") === "Unable to copy. Try again"); assert(rootElement.querySelector('[data-testid="custom-copy"]')); assert(!rootElement.querySelector('[data-testid="custom-copied"]')); @@ -199,9 +202,8 @@ describe("MessageActionBar", () => { "Unable to copy to clipboard", ); assertEquals(document.querySelectorAll("textarea").length, 0); - - await unmount(root); } finally { + if (root) await unmountReactRoot(root); dom.restore(); } }); diff --git a/src/react/components/chat/chat/components/sidebar.test.tsx b/src/react/components/chat/chat/components/sidebar.test.tsx index edc43a4ad9..55a831ad4e 100644 --- a/src/react/components/chat/chat/components/sidebar.test.tsx +++ b/src/react/components/chat/chat/components/sidebar.test.tsx @@ -8,6 +8,7 @@ import { flushSync } from "react-dom"; import { renderToString } from "react-dom/server"; import * as React from "react"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { ChatSidebar, useChatSidebarItem } from "./sidebar.tsx"; @@ -100,7 +101,7 @@ describe("ChatSidebar — conversation-native", () => { assert(html.includes("First chat"), "lists the first conversation from context"); assert(html.includes("Second chat"), "lists the second conversation from context"); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreDom(); @@ -136,7 +137,7 @@ describe("ChatSidebar — conversation-native", () => { "the primary action carries the conversation label", ); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreDom(); @@ -249,7 +250,7 @@ describe("ChatSidebar.Item — menu compound (E4 acid test)", () => { ); assert(itemRef.current.querySelector("input"), "the ref targets the active rename row"); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreDom(); @@ -284,7 +285,7 @@ describe("ChatSidebarRenameEditor", () => { input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); }); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); return [commits, cancels]; } finally { @@ -318,7 +319,7 @@ describe("ChatSidebarRenameEditor", () => { flushSync(() => root.render(renderEditor("Edited title"))); assertEquals(input.getAttribute("aria-label"), "Rename Original title"); - flushSync(() => root.unmount()); + await unmountReactRoot(root); await settle(); } finally { restoreDom(); diff --git a/src/react/components/chat/chat/composition/chat-composer.test.tsx b/src/react/components/chat/chat/composition/chat-composer.test.tsx index ad5688db97..6783ab761e 100644 --- a/src/react/components/chat/chat/composition/chat-composer.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.test.tsx @@ -1,5 +1,5 @@ import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { createRoot, type Root } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { type AnchorHTMLAttributes, @@ -11,6 +11,7 @@ import { type ReactElement, } from "react"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { ChatInput } from "./chat-composer.tsx"; @@ -105,7 +106,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(sends, 1, "the additive API must not break a legacy owned submit"); }); - it("leaves legacy controlled submit policy with the caller", () => { + it("leaves legacy controlled submit policy with the caller", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -113,6 +114,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { const restore = installDomGlobals(dom); let submits = 0; let defaultPrevented: boolean | undefined; + let root: Root | undefined; function Capture(): ReactElement { const submit = useChatInputContext().onSubmit; return
submit(event)} data-submit-probe />; @@ -121,9 +123,10 @@ describe("react/components/chat/chat/composition/chat-composer", () => { try { const rootElement = document.getElementById("root"); assert(rootElement, "Expected root element to exist"); - const root = createRoot(rootElement); + const createdRoot = createRoot(rootElement); + root = createdRoot; flushSync(() => { - root.render( + createdRoot.render( {}} @@ -143,13 +146,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(submits, 1, "controlled submit remains caller-owned"); assertEquals(defaultPrevented, false, "controlled submit receives the event unchanged"); - root.unmount(); } finally { + if (root) await unmountReactRoot(root); restore(); } }); - it("prevents native submission from the default controlled composer form", () => { + it("prevents native submission from the default controlled composer form", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -190,7 +193,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(submitEvent.defaultPrevented, true); assertEquals(dispatchResult, false, "cancelled native submission must report false"); } finally { - root?.unmount(); + if (root) await unmountReactRoot(root); restore(); } }); @@ -247,7 +250,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assert(pending.includes("disabled"), "a pending upload disables the send control"); }); - it("renders an accessible textarea with its native attributes and ref", () => { + it("renders an accessible textarea with its native attributes and ref", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -281,13 +284,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(textarea.rows, 4); assertEquals(textarea.cols, 40); assertEquals(textarea.wrap, "soft"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("opens upload and select document actions from the attachment button", () => { + it("opens upload and select document actions from the attachment button", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -359,13 +362,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { }); assertEquals(selectCalls, 1); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("submits multiline input on Enter and keeps Shift+Enter for newlines", () => { + it("submits multiline input on Enter and keeps Shift+Enter for newlines", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -427,13 +430,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(submitCalls, 1); assertEquals(preventDefaultCalls, 1); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("enables send for a resolved attachment without text", () => { + it("enables send for a resolved attachment without text", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -476,13 +479,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { }); assertEquals(submitCalls, 1, "attachment-only send should submit"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("composer owns submit: folds resolved attachments, clears, and guards uploads", () => { + it("composer owns submit: folds resolved attachments, clears, and guards uploads", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -540,13 +543,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assertEquals(inputSetTo, "", "clears the input after send"); assertEquals(cleared, 1, "clears attachments after send"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("uses the copied Studio prompt shell and non-scaling primary submit button", () => { + it("uses the copied Studio prompt shell and non-scaling primary submit button", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -591,7 +594,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { ); // Studio's submit button does not scale on press. assertEquals(submitButton.className.includes("active:scale"), false); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -643,7 +646,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { assert(voice.includes('aria-describedby="voice-help"')); }); - it("omits an unavailable Stop action while preserving an explicit custom action", () => { + it("omits an unavailable Stop action while preserving an explicit custom action", async () => { const unavailable = renderToString( {}} isLoading> @@ -667,10 +670,12 @@ describe("react/components/chat/chat/composition/chat-composer", () => { ); const restore = installDomGlobals(dom); let customStops = 0; + let root: Root | undefined; try { - const root = createRoot(document.getElementById("root")!); + const createdRoot = createRoot(document.getElementById("root")!); + root = createdRoot; flushSync(() => { - root.render( + createdRoot.render( {}} isLoading> customStops += 1} /> , @@ -680,13 +685,13 @@ describe("react/components/chat/chat/composition/chat-composer", () => { document.querySelector('button[aria-label="Stop"]')?.click() ); assertEquals(customStops, 1, "the explicit custom Stop action remains functional"); - flushSync(() => root.unmount()); } finally { + if (root) await unmountReactRoot(root); restore(); } }); - it("asChild action leaves preserve Button styling, refs, and event composition", () => { + it("asChild action leaves preserve Button styling, refs, and event composition", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -695,13 +700,15 @@ describe("react/components/chat/chat/composition/chat-composer", () => { const actionRef = createRef(); const childRef = createRef(); const order: string[] = []; + let root: Root | undefined; try { const rootElement = document.getElementById("root"); assert(rootElement, "Expected root element to exist"); - const root = createRoot(rootElement); + const createdRoot = createRoot(rootElement); + root = createdRoot; flushSync(() => { - root.render( + createdRoot.render( {}} @@ -749,8 +756,8 @@ describe("react/components/chat/chat/composition/chat-composer", () => { link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); assertEquals(order, ["child", "wrapper", "submit"]); - root.unmount(); } finally { + if (root) await unmountReactRoot(root); restore(); } }); @@ -849,7 +856,7 @@ describe("react/components/chat/chat/composition/chat-composer", () => { ); }); - it("lets an opaque forwardRef button own its native form semantics", () => { + it("lets an opaque forwardRef button own its native form semantics", async () => { const dom = new JSDOM( '
', { url: "https://example.com/" }, @@ -861,13 +868,15 @@ describe("react/components/chat/chat/composition/chat-composer", () => { >((props, ref) =>
', { pretendToBeVisual: true }, @@ -41,6 +41,10 @@ describe("focus management", () => { focusFirst(panel); assertEquals(document.activeElement, panel); + // Moving focus collapses the selection, and jsdom fires the resulting + // `selectionchange` from a 0ms timer the way a browser does. Yield once so + // it completes before Deno's leak sanitizer inspects the test. + await new Promise((resolve) => setTimeout(resolve, 0)); } finally { dom.window.close(); } diff --git a/src/react/components/ui/list.test.tsx b/src/react/components/ui/list.test.tsx index 6c7821e14b..439dc68d19 100644 --- a/src/react/components/ui/list.test.tsx +++ b/src/react/components/ui/list.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { ListItem } from "./list.tsx"; @@ -103,7 +104,7 @@ describe("ListItem", () => { } }); - it("activates on Enter and Space with native-button timing", () => { + it("activates on Enter and Space with native-button timing", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -148,12 +149,12 @@ describe("ListItem", () => { assertEquals(keyDownCalls, 2); assertEquals(keyUpCalls, 1); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("lets caller handlers cancel synthesized activation", () => { + it("lets caller handlers cancel synthesized activation", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -184,12 +185,12 @@ describe("ListItem", () => { flushSync(() => item.dispatchEvent(keyboardEvent("keyup", " "))); assertEquals(activations, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("renders primary and trailing actions as independently accessible controls", () => { + it("renders primary and trailing actions as independently accessible controls", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -260,13 +261,13 @@ describe("ListItem", () => { assertEquals(legacyRowClicks, 0); assertEquals(activationCurrentTarget, primaryAction); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); assertEquals(primaryActionRef.current, null); restore(); } }); - it("delegates the non-action row surface to its native primary action", () => { + it("delegates the non-action row surface to its native primary action", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -306,12 +307,12 @@ describe("ListItem", () => { assertEquals(primaryActivations, 1); assertEquals(actionActivations, 1); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("does not fall through to legacy onClick after synchronous primary removal", () => { + it("does not fall through to legacy onClick after synchronous primary removal", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -345,12 +346,12 @@ describe("ListItem", () => { assertEquals(primaryActivations, 1); assertEquals(legacyRowClicks, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("retains primary-action provenance without Event.composedPath", () => { + it("retains primary-action provenance without Event.composedPath", async () => { const dom = createDom(); const restore = installDom(dom); const composedPathDescriptor = Object.getOwnPropertyDescriptor( @@ -386,7 +387,7 @@ describe("ListItem", () => { assertEquals(primaryActivations, 1); assertEquals(legacyRowClicks, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); if (composedPathDescriptor) { Object.defineProperty( dom.window.Event.prototype, @@ -401,7 +402,7 @@ describe("ListItem", () => { } }); - it("preserves React 19 callback-ref cleanup for the primary action", () => { + it("preserves React 19 callback-ref cleanup for the primary action", async () => { const dom = createDom(); const restore = installDom(dom); let root: ReturnType | undefined; @@ -433,13 +434,13 @@ describe("ListItem", () => { assert(renderedPrimaryAction); assertEquals(primaryAction, renderedPrimaryAction); - flushSync(() => root?.unmount()); + await unmountReactRoot(root); root = undefined; assertEquals(primaryAction, null); assertEquals(cleanupCalls, 1); assertEquals(nullCalls, 0); } finally { - if (root) flushSync(() => root?.unmount()); + if (root) await unmountReactRoot(root); restore(); } }); @@ -468,7 +469,7 @@ describe("ListItem", () => { } }); - it("cancels a pending Space activation when focus leaves the row", () => { + it("cancels a pending Space activation when focus leaves the row", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -494,12 +495,12 @@ describe("ListItem", () => { flushSync(() => item.dispatchEvent(keyboardEvent("keyup", " "))); assertEquals(activations, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("does not re-arm Space when the caller moves focus during keydown", () => { + it("does not re-arm Space when the caller moves focus during keydown", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -527,12 +528,12 @@ describe("ListItem", () => { flushSync(() => item.dispatchEvent(keyboardEvent("keyup", " "))); assertEquals(activations, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("does not synthesize activation while an IME composition is active", () => { + it("does not synthesize activation while an IME composition is active", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -559,12 +560,12 @@ describe("ListItem", () => { assertEquals(composingEnter.defaultPrevented, false); assertEquals(activations, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("forwards refs without replacing structural active state", () => { + it("forwards refs without replacing structural active state", async () => { const dom = createDom(); const restore = installDom(dom); const root = createRoot(document.getElementById("root")!); @@ -595,7 +596,7 @@ describe("ListItem", () => { }); assertEquals(itemRef.current.getAttribute("data-active"), null); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); assertEquals(itemRef.current, null); restore(); } diff --git a/src/react/components/ui/select.test.tsx b/src/react/components/ui/select.test.tsx index 7021e110fa..1e9942abbc 100644 --- a/src/react/components/ui/select.test.tsx +++ b/src/react/components/ui/select.test.tsx @@ -1,8 +1,9 @@ import type * as React from "react"; import { flushSync } from "react-dom"; -import { createRoot, hydrateRoot, type Root } from "react-dom/client"; +import { createRoot, hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { @@ -109,18 +110,6 @@ function selectedText(trigger: HTMLElement): string | undefined { return activeId ? trigger.ownerDocument.getElementById(activeId)?.textContent?.trim() : undefined; } -/** - * Unmount and drain the scheduler task React leaves behind. - * - * React's scheduler holds a `setImmediate` until it next runs. It completes on - * its own, but the test has to yield once more or Deno's leak sanitizer sees - * the timer still pending. - */ -async function unmount(root: Root): Promise { - flushSync(() => root.unmount()); - await new Promise((resolve) => setTimeout(resolve, 0)); -} - describe("Select", () => { it("links the combobox and listbox and supports the complete keyboard lifecycle", async () => { const dom = createDom(); @@ -201,7 +190,7 @@ describe("Select", () => { assertEquals(trigger.hasAttribute("aria-activedescendant"), false); assertEquals(trigger.textContent?.includes("Gamma"), true); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -337,7 +326,7 @@ describe("Select", () => { assertEquals(calls, ["cancel-trigger", "cancel-key", "cancel-item"]); assertEquals(trigger.getAttribute("aria-expanded"), "true"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -400,7 +389,7 @@ describe("Select", () => { assertEquals(values, ["beta"]); assertEquals(document.activeElement, trigger); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -467,7 +456,7 @@ describe("Select", () => { assertEquals(trigger.getAttribute("aria-expanded"), "false"); assertEquals(openChanges, [true, false, true, false]); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -528,7 +517,7 @@ describe("Select", () => { "controlled owner re-enables its still-open value", ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -576,7 +565,7 @@ describe("Select", () => { assertEquals(trigger.getAttribute("aria-expanded"), "false"); assertEquals(document.getElementById("root-transition-list"), null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -645,7 +634,7 @@ describe("Select", () => { ); assertEquals(openChanges, [false, true]); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -702,7 +691,7 @@ describe("Select", () => { press(trigger, "Home"); await waitFor(() => selectedText(trigger) === "Gamma", "reordered first option"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -762,7 +751,7 @@ describe("Select", () => { ); } finally { nodePrototype.compareDocumentPosition = compareDocumentPosition; - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -839,7 +828,7 @@ describe("Select", () => { assertEquals(document.activeElement, outside); outside.remove(); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -875,7 +864,7 @@ describe("Select", () => { await waitFor(() => trigger.getAttribute("aria-expanded") === "false", "blur close"); assertEquals(document.activeElement, outside); } finally { - await unmount(root); + await unmountReactRoot(root); outside.remove(); restore(); } @@ -934,7 +923,7 @@ describe("Select", () => { assertEquals(listbox.getAttribute("aria-labelledby"), "hydrated-trigger"); assertEquals(group.getAttribute("aria-labelledby"), "models-label"); } finally { - if (root) await unmount(root); + if (root) await unmountReactRoot(root); restore(); } }); @@ -1067,7 +1056,7 @@ describe("Select", () => { assertEquals(trigger.getAttribute("aria-expanded"), "false"); assertEquals(openChanges, [true, false]); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1126,7 +1115,7 @@ describe("Select", () => { assertEquals(trigger.disabled, true); assertEquals(openChanges, [false]); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1263,7 +1252,7 @@ describe("Select", () => { ); assertEquals(calls, ["trigger:attach", "item:attach"]); - await unmount(root); + await unmountReactRoot(root); assertEquals(calls, [ "trigger:attach", "item:attach", diff --git a/src/react/components/ui/slot.test.tsx b/src/react/components/ui/slot.test.tsx index 0eb5e66fb8..48b25e43be 100644 --- a/src/react/components/ui/slot.test.tsx +++ b/src/react/components/ui/slot.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, @@ -109,7 +110,7 @@ describe("Slot", () => { assertStringIncludes(ownedOpaqueButton, 'type="button"'); }); - it("preserves React 19 callback-ref cleanup for both composed refs", () => { + it("preserves React 19 callback-ref cleanup for both composed refs", async () => { const dom = new JSDOM( '
', { pretendToBeVisual: true, url: "https://example.com/" }, @@ -146,13 +147,13 @@ describe("Slot", () => { assertEquals(attached, ["outer", "child"]); assertEquals(cleaned, []); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); assertEquals(cleaned, ["child", "outer"]); restore(); } }); - it("does not run slot behavior after a child handler cancels the event", () => { + it("does not run slot behavior after a child handler cancels the event", async () => { const dom = new JSDOM( '
', { pretendToBeVisual: true, url: "https://example.com/" }, @@ -183,12 +184,12 @@ describe("Slot", () => { ); assertEquals(slotCalls, 0); } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); - it("blocks disabled activation, handlers, and propagation", () => { + it("blocks disabled activation, handlers, and propagation", async () => { const dom = new JSDOM( '
', { pretendToBeVisual: true, url: "https://example.com/start" }, @@ -276,7 +277,7 @@ describe("Slot", () => { assertEquals(calls, []); } } finally { - flushSync(() => root.unmount()); + await unmountReactRoot(root); restore(); } }); diff --git a/src/react/components/ui/tooltip.test.tsx b/src/react/components/ui/tooltip.test.tsx index 8863000ff0..489e3be3c2 100644 --- a/src/react/components/ui/tooltip.test.tsx +++ b/src/react/components/ui/tooltip.test.tsx @@ -1,8 +1,9 @@ import type { RefCallback } from "react"; import { flushSync } from "react-dom"; -import { createRoot, hydrateRoot, type Root } from "react-dom/client"; +import { createRoot, hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip.tsx"; @@ -100,12 +101,21 @@ function escape(window: JSDOM["window"], target: EventTarget): KeyboardEvent { return event; } -// Unmounting leaves a scheduler callback queued; drain it so the leak -// sanitizer does not attribute that timer to the test. -async function unmount(root: Root | undefined): Promise { - if (!root) return; - flushSync(() => root.unmount()); - await new Promise((resolve) => setTimeout(resolve, 0)); +/** + * Wait for the Escape dismissal layer to be registered. + * + * `tooltip.tsx` picks its effect hook at module load, and `document` only + * exists once a test installs a JSDOM, so under Deno every one of its "layout" + * effects is really a passive effect. The tooltip node therefore lands one + * commit before `registerDismissableLayer` runs. A keydown is one-shot -- an + * Escape dispatched in that window is dropped for good -- so yield the + * macrotask React's scheduler queued at commit time before pressing it. + * + * Browsers define `document` at module load, bind `useLayoutEffect`, and + * register within the commit, so this gap is a test-environment artifact. + */ +function escapeLayerRegistered(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); } describe("Tooltip", () => { @@ -148,7 +158,7 @@ describe("Tooltip", () => { assert(tooltipId); assertEquals(defaultTrigger.getAttribute("aria-describedby"), tooltipId); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -233,7 +243,7 @@ describe("Tooltip", () => { assertEquals(childFocusCalls, 1); assertEquals(recoverableErrors, []); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -271,7 +281,7 @@ describe("Tooltip", () => { await waitFor(() => document.querySelector('[role="tooltip"]') === null); assertEquals(trigger.hasAttribute("aria-describedby"), false); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -301,6 +311,7 @@ describe("Tooltip", () => { assert(trigger); flushSync(() => hover(dom.window, trigger)); await waitFor(() => document.querySelector('[role="tooltip"]') !== null); + await escapeLayerRegistered(); const cancelledEscape = escape(dom.window, trigger); assertEquals(cancelledEscape.defaultPrevented, true); @@ -321,7 +332,7 @@ describe("Tooltip", () => { }); await waitFor(() => document.querySelector('[role="tooltip"]') !== null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -364,7 +375,7 @@ describe("Tooltip", () => { assertEquals(calls, ["child", "trigger"]); assert(document.querySelector('[role="tooltip"]') === null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -399,7 +410,7 @@ describe("Tooltip", () => { flushSync(() => unhover(dom.window, trigger)); await waitFor(() => document.querySelector('[role="tooltip"]') === null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -442,12 +453,12 @@ describe("Tooltip", () => { const unmountedTrigger = document.querySelector("button"); assert(unmountedTrigger); flushSync(() => hover(dom.window, unmountedTrigger)); - flushSync(() => root.unmount()); + await unmountReactRoot(root); rootMounted = false; await new Promise((resolve) => setTimeout(resolve, 50)); assert(document.querySelector('[role="tooltip"]') === null); } finally { - if (rootMounted) await unmount(root); + if (rootMounted) await unmountReactRoot(root); restore(); } }); @@ -538,7 +549,7 @@ describe("Tooltip", () => { flushSync(() => escape(targetDom.window, targetDocument)); await waitFor(() => targetDocument.querySelector('[role="tooltip"]') === null); } finally { - await unmount(root); + await unmountReactRoot(root); targetDom.window.close(); restore(); } @@ -579,7 +590,7 @@ describe("Tooltip", () => { assertEquals(tooltip.style.overflowWrap, "normal"); assertEquals(tooltip.style.whiteSpace, "pre-wrap"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -607,7 +618,7 @@ describe("Tooltip", () => { flushSync(() => hover(dom.window, trigger)); await waitFor(() => document.querySelector('[role="tooltip"]') !== null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -662,7 +673,7 @@ describe("Tooltip", () => { flushSync(() => unhover(dom.window, trigger)); assertEquals(clearedHandles, [47]); } finally { - await unmount(root); + await unmountReactRoot(root); if (setTimeoutDescriptor) { Object.defineProperty(dom.window, "setTimeout", setTimeoutDescriptor); } else delete (dom.window as unknown as Record).setTimeout; @@ -712,7 +723,7 @@ describe("Tooltip", () => { assertEquals(cleanupCalls, 0); assertEquals(nullCalls, 0); } finally { - await unmount(root); + await unmountReactRoot(root); assertEquals(attachedElement, null); assertEquals(cleanupCalls, 1); assertEquals(nullCalls, 0); @@ -762,7 +773,7 @@ describe("Tooltip", () => { assert(document.querySelector('[role="tooltip"]')); assertEquals(refCalls, ["attach:1", "cleanup:1", "attach:2"]); } finally { - await unmount(root); + await unmountReactRoot(root); assertEquals(refCalls, [ "attach:1", "cleanup:1", diff --git a/src/react/primitives/input-box.test.tsx b/src/react/primitives/input-box.test.tsx index 312e0c4d98..ab5af980ef 100644 --- a/src/react/primitives/input-box.test.tsx +++ b/src/react/primitives/input-box.test.tsx @@ -2,6 +2,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type * as React from "react"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { handleInputBoxKeyDown, InputBox, SubmitButton } from "./input-box.tsx"; @@ -119,11 +120,11 @@ describe("InputBox", () => { await waitFor(() => textarea.style.height === "120px"); assert(refValues.includes(textarea)); - flushSync(() => root?.unmount()); + if (root) await unmountReactRoot(root); root = undefined; assertEquals(refCleanupCalls, 1); } finally { - if (root) flushSync(() => root?.unmount()); + if (root) await unmountReactRoot(root); restore(); } }); @@ -173,7 +174,7 @@ describe("InputBox", () => { }); describe("SubmitButton", () => { - it("chains stop callbacks while protecting button type and stop availability", () => { + it("chains stop callbacks while protecting button type and stop availability", async () => { const dom = createDom(); const restore = installDom(dom); let root: ReturnType | undefined; @@ -223,12 +224,12 @@ describe("SubmitButton", () => { assertEquals(stopCalls, 1); assertEquals(submitCalls, 0); } finally { - root?.unmount(); + if (root) await unmountReactRoot(root); restore(); } }); - it("chains voice callbacks without allowing a submit type override", () => { + it("chains voice callbacks without allowing a submit type override", async () => { const dom = createDom(); const restore = installDom(dom); let root: ReturnType | undefined; @@ -263,12 +264,12 @@ describe("SubmitButton", () => { assertEquals(clickCalls, 1); assertEquals(voiceCalls, 1); } finally { - root?.unmount(); + if (root) await unmountReactRoot(root); restore(); } }); - it("allows the caller click handler to cancel a stop action deliberately", () => { + it("allows the caller click handler to cancel a stop action deliberately", async () => { const dom = createDom(); const restore = installDom(dom); let root: ReturnType | undefined; @@ -302,12 +303,12 @@ describe("SubmitButton", () => { assertEquals(clickCalls, 1); assertEquals(stopCalls, 0); } finally { - root?.unmount(); + if (root) await unmountReactRoot(root); restore(); } }); - it("keeps a disabled submit state inert", () => { + it("keeps a disabled submit state inert", async () => { const dom = createDom(); const restore = installDom(dom); let root: ReturnType | undefined; @@ -336,7 +337,7 @@ describe("SubmitButton", () => { flushSync(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true }))); assertEquals(clickCalls, 0); } finally { - root?.unmount(); + if (root) await unmountReactRoot(root); restore(); } }); diff --git a/src/react/react-root.test-helpers.ts b/src/react/react-root.test-helpers.ts new file mode 100644 index 0000000000..6e86f85ab1 --- /dev/null +++ b/src/react/react-root.test-helpers.ts @@ -0,0 +1,15 @@ +import { flushSync } from "react-dom"; +import type { Root } from "react-dom/client"; + +/** + * Unmount a React root and drain the scheduler task left by the commit. + * + * React may retain a pending scheduler callback after `Root.unmount()`. Tests + * that use Deno's leak sanitizer must yield once before restoring their DOM + * globals or returning. + */ +export async function unmountReactRoot(root: Root | undefined): Promise { + if (!root) return; + flushSync(() => root.unmount()); + await new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/src/react/runtime/head-client.test.tsx b/src/react/runtime/head-client.test.tsx index 28aba9757f..66ede830a5 100644 --- a/src/react/runtime/head-client.test.tsx +++ b/src/react/runtime/head-client.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot, hydrateRoot, type Root } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, @@ -113,12 +114,6 @@ function rootElement(id = "root"): HTMLElement { return element; } -async function unmount(root: Root | undefined): Promise { - if (!root) return; - flushSync(() => root.unmount()); - await nextTask(); -} - describe("Head client management", () => { it("suppresses the expected server-only commit-token difference during hydration", async () => { const children = Hydrated managed head; @@ -150,7 +145,7 @@ describe("Head client management", () => { assertEquals(consoleErrors, []); } finally { console.error = previousConsoleError; - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -199,7 +194,7 @@ describe("Head client management", () => { assertEquals(thirdParty.getAttribute("data-vf-react-head"), null); assertEquals(thirdParty.getAttribute("content"), "Third party"); - await unmount(root); + await unmountReactRoot(root); root = undefined; assertStrictEquals(document.head.querySelector("title"), shellTitle); @@ -218,7 +213,7 @@ describe("Head client management", () => { assertEquals(element.getAttribute("data-veryfront-managed"), null); } } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -252,7 +247,7 @@ describe("Head client management", () => { assertEquals(document.head.querySelector("title"), null); assertEquals(document.head.querySelector('meta[name="description"]'), null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -308,7 +303,7 @@ describe("Head client management", () => { assertEquals(document.head.querySelectorAll('link[rel="canonical"]').length, 1); assertEquals(document.head.querySelectorAll("#route-state").length, 1); - await unmount(root); + await unmountReactRoot(root); root = undefined; for (const element of [routeTitle, routeDescription, routeCanonical, routeScript]) { @@ -323,7 +318,7 @@ describe("Head client management", () => { assertEquals(routeCanonical.getAttribute("href"), "https://example.com/route"); assertEquals(routeScript.textContent, scriptContent); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -362,7 +357,7 @@ describe("Head client management", () => { assertEquals(document.head.querySelectorAll("#route-exec").length, 1); assertStrictEquals(document.getElementById("route-exec"), routeScript); - await unmount(root); + await unmountReactRoot(root); root = undefined; assertEquals(scriptWindow.__routeExecRuns, executionsBeforeReactAdoption); @@ -372,7 +367,7 @@ describe("Head client management", () => { "window.__routeExecRuns=(window.__routeExecRuns||0)+1;", ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -427,7 +422,7 @@ describe("Head client management", () => { assertEquals(managedManifest.getAttribute("data-vf-react-head"), null); assertStrictEquals(document.getElementById("third-party"), thirdParty); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -492,7 +487,7 @@ describe("Head client management", () => { assertEquals(element.getAttribute("data-veryfront-managed"), "1"); } } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -516,7 +511,7 @@ describe("Head client management", () => { assertStrictEquals(document.head.querySelector("style"), original); assertEquals(original?.getAttribute("nonce"), "response"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -600,7 +595,7 @@ describe("Head client management", () => { ".two{}", ); - await unmount(root); + await unmountReactRoot(root); root = undefined; assertEquals( frameDocument.head.querySelectorAll( @@ -617,7 +612,7 @@ describe("Head client management", () => { primaryMeta, ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -645,7 +640,7 @@ describe("Head client management", () => { assertEquals(original?.getAttribute("defer"), ""); assertEquals(original?.getAttribute("data-enabled"), "false"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -678,7 +673,7 @@ describe("Head client management", () => { assertEquals(originalMeta?.getAttribute("content"), "A\nB\nC"); assertEquals(originalStyle?.textContent, ".a{\ncolor:red\n}"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -704,7 +699,7 @@ describe("Head client management", () => { assertStrictEquals(document.head.querySelector("meta[charset]"), charset); assertEquals(charset?.hasAttribute("data-vf-react-head"), false); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -739,7 +734,7 @@ describe("Head client management", () => { assertEquals(scriptWindow.__payload, ""); assertStrictEquals(document.head.querySelector("#once"), original); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -789,7 +784,7 @@ describe("Head client management", () => { assertNotStrictEquals(document.head.querySelector("#versioned"), original); assertEquals(document.head.querySelectorAll("#versioned").length, 1); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -865,7 +860,7 @@ describe("Head client management", () => { assertEquals(document.head.querySelector("style")?.textContent, ".two{color:blue}"); assertEquals(scriptWindow.__updateRuns, 1); - await unmount(root); + await unmountReactRoot(root); root = undefined; assertEquals(document.head.querySelectorAll('[data-vf-react-head="true"]').length, 0); assertEquals(document.head.querySelector("title"), null); @@ -874,7 +869,7 @@ describe("Head client management", () => { assertEquals(document.head.querySelector("style"), null); assertEquals(document.head.querySelector("#stable"), null); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -951,7 +946,7 @@ describe("Head client management", () => { assertEquals(imageOrder(), ["layout.jpg"]); assertEquals(document.head.querySelector("#shared")?.getAttribute("src"), "/layout.js"); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -979,7 +974,7 @@ describe("Head client management", () => { await nextTask(); assertEquals(authors(), ["B", "A"]); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1021,7 +1016,7 @@ describe("Head client management", () => { 1, ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1066,8 +1061,8 @@ describe("Head client management", () => { 1, ); } finally { - await unmount(rootA); - await unmount(rootB); + await unmountReactRoot(rootA); + await unmountReactRoot(rootB); restore(); } }); @@ -1118,7 +1113,7 @@ describe("Head client management", () => { 1, ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1149,7 +1144,7 @@ describe("Head client management", () => { assertEquals(scriptWindow.__strictRuns, 1); assertEquals(document.head.querySelectorAll("#strict-once").length, 1); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1197,7 +1192,7 @@ describe("Head client management", () => { null, ); } finally { - await unmount(root); + await unmountReactRoot(root); restore(); } }); @@ -1265,7 +1260,7 @@ describe("Head client management", () => { assertEquals(meta.hasAttribute("onclick"), false); assertEquals(accessorCalls, 0); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } diff --git a/src/react/runtime/router-provider.test.tsx b/src/react/runtime/router-provider.test.tsx index fac3557ccc..3ece32ebeb 100644 --- a/src/react/runtime/router-provider.test.tsx +++ b/src/react/runtime/router-provider.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { waitFor } from "#veryfront/testing/deno-compat.ts"; @@ -159,7 +160,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "thread:b"); assertEquals(router.navigateCount, 1); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -196,7 +197,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "tab:b"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -231,7 +232,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "/|x"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -268,13 +269,13 @@ describe("react/runtime/RouterProvider (reactive)", () => { // Router changed → page context's query tracked it; frontmatter unchanged. assertStringIncludes(rootElement.textContent ?? "", "match:true|v:2|fm:kept"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } }); - it("derives a clean query when the URL carries a hash fragment", () => { + it("derives a clean query when the URL carries a hash fragment", async () => { const restore = installDom("https://example.com/docs?tab=api#install"); installFakeRouter(); try { @@ -297,7 +298,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { // The hash must not leak into the query — only `tab` is present. assertStringIncludes(rootElement.textContent ?? "", "/docs|tab:api|keys:tab"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -332,7 +333,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "42|comments|Hello"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -373,7 +374,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { // query/params. assertStringIncludes(rootElement.textContent ?? "", "data:from-server"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -408,7 +409,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(html, "type:object keys:0"); }); - it("wrapForHydration seeds the provider from location + params (no React passed in)", () => { + it("wrapForHydration seeds the provider from location + params (no React passed in)", async () => { // The hydration path wraps a child by calling this export on the app's own // React — nothing is threaded across the module boundary. It seeds params // and frontmatter and derives pathname/query from the live URL. @@ -434,7 +435,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "/posts/7:7:x:Hi"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } @@ -475,7 +476,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { { interval: 10, message: "RouterProvider did not finish its client mount" }, ); assertStringIncludes(rootElement.textContent ?? "", "m:true"); - root.unmount(); + await unmountReactRoot(root); } finally { restoreClient(); } @@ -515,7 +516,7 @@ describe("react/runtime/RouterProvider (reactive)", () => { assertStringIncludes(rootElement.textContent ?? "", "tab:b"); - root.unmount(); + await unmountReactRoot(root); } finally { restore(); } diff --git a/src/routing/api/module-loader/esbuild-plugin.test.ts b/src/routing/api/module-loader/esbuild-plugin.test.ts index 0f20689559..910ed5535d 100644 --- a/src/routing/api/module-loader/esbuild-plugin.test.ts +++ b/src/routing/api/module-loader/esbuild-plugin.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { observeFetchRequestInit } from "#veryfront/testing/mock-fetch.ts"; import { computeIntegrity, type LockfileManager } from "#veryfront/utils"; import { MAX_BUNDLE_CHUNK_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; import { createHTTPPlugin } from "./esbuild-plugin.ts"; @@ -562,7 +563,7 @@ describe("routing/api/module-loader/esbuild-plugin", () => { try { globalThis.fetch = ((_input, init) => { attempts += 1; - const signal = init?.signal; + const signal = observeFetchRequestInit(init).signal; return Promise.resolve( new Response( new ReadableStream({