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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/stress.html
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ <h3>Card ${i + 1}</h3>
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);
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion scripts/browser-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions scripts/lib/browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions scripts/react-doctor.mjs
Original file line number Diff line number Diff line change
@@ -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"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin the executable used by the release gate

Pin react-doctor to a reviewed, lockfile-controlled version instead of executing @latest. The publish path runs bun run release, which invokes bun run check and this script from the privileged Changesets step in .github/workflows/release.yml; that step exposes a write-capable GITHUB_TOKEN and has id-token: write. A compromised or newly malicious registry release could therefore execute arbitrary code with release credentials, while even a benign incompatible update can unexpectedly block publishing.

Useful? React with 👍 / 👎.

{
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);
17 changes: 8 additions & 9 deletions src/react/useRageLayer.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<RageLayerController | null>(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<RageLayerEngine | null>(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 };
}
3 changes: 2 additions & 1 deletion tests/fixtures/tool-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ <h1><span id="tool-label">Tool</span> on fixed wood</h1>
"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(", ")}`);
}
Expand Down
46 changes: 45 additions & 1 deletion tests/react-component.test.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
Loading