From 363b775fa3742320d3a1c814e01bf1a711c10e26 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Tue, 29 Sep 2026 02:37:56 +0530 Subject: [PATCH] Require React Doctor 100 in local verification --- MAINTAINERS.md | 7 ++++ README.md | 3 +- benchmarks/stress.html | 2 +- package.json | 5 +-- scripts/browser-test.mjs | 3 +- scripts/lib/browser.mjs | 6 ++-- scripts/react-doctor.mjs | 58 ++++++++++++++++++++++++++++++++++ src/react/useRageLayer.ts | 17 +++++----- tests/fixtures/tool-demo.html | 3 +- tests/react-component.test.mjs | 46 ++++++++++++++++++++++++++- 10 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 scripts/react-doctor.mjs 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); +});