diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index 763f175..6968f5a 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -43,3 +43,10 @@ Two traps in that pin. VitePress 1.x cannot build on vite 8: the render step cal
"rendering pages". And a local `bun run docs:build` can pass anyway when a stale `node_modules`
still carries `esbuild` from an earlier install — verify the bump on a clean install, or let CI do
it, because that is the environment that decides.
+
+## React Doctor
+
+`bun run check` includes `bun run doctor`, which runs `npx react-doctor@latest` over the full
+project and requires a complete 100/100 score. It saves the JSON report in
+`node_modules/.cache/react-doctor/report.json`. Network or incomplete scans fail the gate;
+fix findings and rerun rather than accepting a partial score.
diff --git a/README.md b/README.md
index 2f7dcae..ca27e34 100644
--- a/README.md
+++ b/README.md
@@ -275,7 +275,8 @@ damage. See [capture and framework integration notes](./docs/integrations.md).
```sh
bun install
-bun run check # types, lint, unit tests + coverage floors, build, package validation
+bun run check # types, lint, tests + coverage, build, package validation, Doctor 100
+bun run doctor # complete React Doctor scan; requires 100/100 and network access
bun run test:browser # runtime suite in headless Chrome (real WebGL, real capture)
bun run demo:tools # record all 15 tools as a video reel for review
bun run benchmark:low-end # fixed workloads with 6× CPU throttling
diff --git a/benchmarks/stress.html b/benchmarks/stress.html
index 30f56f6..4fc2f60 100644
--- a/benchmarks/stress.html
+++ b/benchmarks/stress.html
@@ -491,7 +491,7 @@
Card ${i + 1}
const longTasks = [];
const snapshots = [];
const offSnapshot = engine.onPerformance((snapshot) =>
- snapshots.push(JSON.parse(JSON.stringify(snapshot))),
+ snapshots.push(structuredClone(snapshot)),
);
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) longTasks.push(entry.duration);
diff --git a/package.json b/package.json
index 4042f99..12639a5 100644
--- a/package.json
+++ b/package.json
@@ -150,7 +150,7 @@
"audit": "bun audit",
"check:artifacts": "node scripts/check-local-artifacts.mjs",
"check:package": "node scripts/check-dist.mjs && publint && attw --pack . --profile esm-only",
- "check": "bun run typecheck && bun run lint && bun run check:artifacts && bun run test:coverage && bun run build && bun run typecheck:consumers && bun run check:package",
+ "check": "bun run typecheck && bun run lint && bun run check:artifacts && bun run test:coverage && bun run build && bun run typecheck:consumers && bun run check:package && bun run doctor",
"docs:dev": "vitepress dev docs",
"docs:build": "node scripts/build-docs.mjs",
"docs:preview": "vitepress preview docs",
@@ -167,7 +167,8 @@
"changeset": "changeset",
"version": "changeset version",
"release": "bun run check && changeset publish",
- "prepack": "bun run build"
+ "prepack": "bun run build",
+ "doctor": "node scripts/react-doctor.mjs"
},
"keywords": [
"canvas",
diff --git a/scripts/browser-test.mjs b/scripts/browser-test.mjs
index 30f09ee..78d5778 100644
--- a/scripts/browser-test.mjs
+++ b/scripts/browser-test.mjs
@@ -753,7 +753,8 @@ try {
"document.documentElement.dataset.ready === 'true'",
{ label: "the demo page to finish booting" },
);
- await waitFor(browser.cdp, browser.sessionId, "window.engine && window.engine.content", {
+ // Return readiness only; serializing the capture object can stall CDP on its pixel buffers.
+ await waitFor(browser.cdp, browser.sessionId, "Boolean(window.engine?.content)", {
label: "the page capture to complete",
});
await run(HARNESS);
diff --git a/scripts/lib/browser.mjs b/scripts/lib/browser.mjs
index e8b2e1d..5dbe589 100644
--- a/scripts/lib/browser.mjs
+++ b/scripts/lib/browser.mjs
@@ -171,8 +171,10 @@ function readChromeVersion(chromePath) {
export async function launchChrome({ url, flags = [], cpuRate = 1 } = {}) {
const chromePath = resolveChromePath();
const isHeadlessShell = /headless[-_]shell/.test(chromePath);
- const debugPort = await freePort();
- const profileDir = await mkdtemp(join(tmpdir(), "ragelayer-"));
+ const [debugPort, profileDir] = await Promise.all([
+ freePort(),
+ mkdtemp(join(tmpdir(), "ragelayer-")),
+ ]);
const chrome = spawn(
chromePath,
diff --git a/scripts/react-doctor.mjs b/scripts/react-doctor.mjs
new file mode 100644
index 0000000..908f23c
--- /dev/null
+++ b/scripts/react-doctor.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const result = spawnSync(
+ "npx",
+ ["--yes", "react-doctor@latest", ".", "--yes", "--scope", "full", "--json"],
+ {
+ cwd: root,
+ encoding: "utf8",
+ maxBuffer: 32 * 1024 * 1024,
+ },
+);
+if (result.error || !result.stdout?.trim()) {
+ console.error(result.error?.message ?? result.stderr ?? "React Doctor returned no report.");
+ process.exit(1);
+}
+let report;
+try {
+ report = JSON.parse(result.stdout);
+} catch {
+ console.error("React Doctor returned an invalid report:", result.stdout, result.stderr);
+ process.exit(1);
+}
+const output = new URL("../node_modules/.cache/react-doctor/", import.meta.url);
+mkdirSync(output, { recursive: true });
+writeFileSync(new URL("report.json", output), JSON.stringify(report, null, 2) + "\n");
+for (const finding of report.diagnostics ?? []) {
+ console.log(
+ `${finding.filePath}:${finding.line} ${finding.severity} ${finding.plugin}/${finding.rule}: ${finding.message}`,
+ );
+}
+const projects = report.projects ?? [];
+const complete =
+ report.ok === true &&
+ report.mode === "full" &&
+ report.reactDetected === true &&
+ projects.length > 0 &&
+ projects.every(
+ (project) =>
+ project.complete === true &&
+ project.analyzedFileCount > 0 &&
+ project.skippedChecks?.length === 0,
+ );
+const perfect =
+ report.summary?.score === 100 && projects.every((project) => project.score?.score === 100);
+console.log(
+ `React Doctor ${report.version ?? ""}: ${report.summary?.score ?? "unavailable"}/100 (required: 100).`,
+);
+console.log("Report: node_modules/.cache/react-doctor/report.json");
+if (!complete)
+ console.error(
+ "React Doctor did not complete every check; retry when the scanner and its services are available.",
+ );
+if (!perfect) console.error("Fix the findings and rerun the full scan to reach 100.");
+process.exit(result.status === 0 && complete && perfect ? 0 : 1);
diff --git a/src/react/useRageLayer.ts b/src/react/useRageLayer.ts
index 12a080e..3327123 100644
--- a/src/react/useRageLayer.ts
+++ b/src/react/useRageLayer.ts
@@ -1,8 +1,8 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import type { RageLayerEngine } from "../engine";
-import { createRageLayer, type MountRageLayerOptions, type RageLayerController } from "../mount";
+import { createRageLayer, type MountRageLayerOptions } from "../mount";
export interface UseRageLayerResult {
engine: RageLayerEngine | null;
@@ -14,22 +14,21 @@ export interface UseRageLayerResult {
/** Headless React binding for applications that provide their own controls. */
export function useRageLayer(options: MountRageLayerOptions = {}): UseRageLayerResult {
- const controllerRef = useRef(null);
- if (!controllerRef.current) controllerRef.current = createRageLayer(options);
+ // Creating the controller is inert; only open() mounts the engine.
+ const [controller] = useState(() => createRageLayer(options));
const [engine, setEngine] = useState(null);
useEffect(() => {
- const controller = controllerRef.current!;
const unsubscribe = controller.subscribe(setEngine);
return () => {
unsubscribe();
controller.close();
};
- }, []);
+ }, [controller]);
- const open = useCallback(() => controllerRef.current!.open(), []);
- const close = useCallback(() => controllerRef.current!.close(), []);
- const toggle = useCallback(() => controllerRef.current!.toggle(), []);
+ const open = useCallback(() => controller.open(), [controller]);
+ const close = useCallback(() => controller.close(), [controller]);
+ const toggle = useCallback(() => controller.toggle(), [controller]);
return { engine, isOpen: engine !== null, open, close, toggle };
}
diff --git a/tests/fixtures/tool-demo.html b/tests/fixtures/tool-demo.html
index c7eda13..d39559c 100644
--- a/tests/fixtures/tool-demo.html
+++ b/tests/fixtures/tool-demo.html
@@ -104,9 +104,10 @@ Tool on fixed wood
"acid-sprayer", "sticky-bombs", "broom",
];
const actualToolIds = defaultTools.map((tool) => tool.id);
+ const actualToolIdSet = new Set(actualToolIds);
if (
actualToolIds.length !== toolIds.length ||
- toolIds.some((toolId) => !actualToolIds.includes(toolId))
+ toolIds.some((toolId) => !actualToolIdSet.has(toolId))
) {
throw new Error(`Unexpected built-in tools: ${actualToolIds.join(", ")}`);
}
diff --git a/tests/react-component.test.mjs b/tests/react-component.test.mjs
index 87eba3b..8b63c3f 100644
--- a/tests/react-component.test.mjs
+++ b/tests/react-component.test.mjs
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { act, createElement } from "react";
+import { act, createElement, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RageLayer } from "../src/react/RageLayer.tsx";
+import { useRageLayer } from "../src/react/useRageLayer.ts";
import { broom, hammer } from "../src/tools.ts";
import { setViewport } from "./support/dom.mjs";
@@ -77,3 +78,46 @@ describe("shared toolbar state", () => {
expect(window.__rageLayer).toBeUndefined();
});
});
+
+test("the headless hook survives StrictMode and rerenders, then disposes on unmount", async () => {
+ let session;
+ function Controls({ options }) {
+ session = useRageLayer(options);
+ return null;
+ }
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ const render = () =>
+ root.render(
+ createElement(
+ StrictMode,
+ null,
+ createElement(Controls, { options: { captureContent: false, tools: [hammer] } }),
+ ),
+ );
+ await act(async () => render());
+ expect(session.isOpen).toBe(false);
+ expect(session.engine).toBeNull();
+ const open = session.open;
+ let engine;
+ await act(async () => {
+ engine = session.open();
+ });
+ expect(session.engine).toBe(engine);
+ expect(session.isOpen).toBe(true);
+ await act(async () => render());
+ expect(session.open).toBe(open);
+ expect(session.engine).toBe(engine);
+ expect(session.open()).toBe(engine);
+ await act(async () => session.close());
+ expect(session.isOpen).toBe(false);
+ expect(engine.disposed).toBe(true);
+ await act(async () => {
+ engine = session.toggle();
+ });
+ expect(session.isOpen).toBe(true);
+ await act(async () => root.unmount());
+ root = undefined;
+ expect(engine.disposed).toBe(true);
+});