Skip to content

Commit b629b12

Browse files
committed
feat(react): /react umbrella skill + in-house browser core + debug-agent debug job
Rename the agent skill command to /react (SKILL_NAME) and grow it into a routing umbrella: ten always-on React baseline rules plus prose routing across doctor (static scan), perf (the DevTools profiling harness), debug, design, and grab. Add @react-doctor/browser, a thin playwright-core wrapper that attaches to a running Chrome over CDP (launching one only as a fallback) plus a lazily-loaded `react-doctor browser` command (open/eval/snapshot/screenshot/grab) backing the debug, design, and grab jobs. Playwright never bundles into the CLI hot path. The debug job runs the debug-agent (github.com/millionco/debug-agent) runtime-evidence loop: hypothesize, instrument with NDJSON logs, reproduce in the live browser, fix only with log proof, verify, then clean up. Carries the doc.md plan and references/{debug,design,grab}.md playbooks.
1 parent 967a07d commit b629b12

28 files changed

Lines changed: 1028 additions & 99 deletions

.changeset/react-skill-umbrella.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-doctor": minor
3+
---
4+
5+
Rename the skill command to `/react` and expand it into a routing umbrella: ten always-on React baseline rules plus prose routing across doctor (static scan), perf (the DevTools profiling harness), debug, design, and grab. The debug job runs the [debug-agent](https://github.com/millionco/debug-agent) runtime-evidence loop (hypothesize → instrument with NDJSON logs → reproduce → fix with proof → verify). Adds an in-house browser core (`@react-doctor/browser`) that attaches to a running Chrome over CDP (launching one only as a fallback) and a `react-doctor browser` command (open/eval/snapshot/screenshot/grab) that backs the debug, design, and grab jobs. The browser core loads lazily, so playwright never bundles into the CLI's hot path.

doc.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# React Doctor: one skill that makes agents good at React
2+
3+
Today React Doctor is a code scanner. The plan is to grow it into a single skill, `/react`, that does more than scan: it helps a coding agent write better React, quietly checks its own changes in the background, and can open a real browser to measure speed, reproduce bugs, and review design. People pick it up one developer at a time because the local skill is free and useful on its own, and the way it spreads to a whole team is by getting added to continuous integration (CI).
4+
5+
## What it is
6+
7+
It is the skill we already ship, grown, rather than a new wrapper around a pile of separate tools. The cleanup pass it does today becomes one option among several, and [React Grab](https://github.com/aidenybai/react-grab) and the browser piece from [Expect](https://github.com/millionco/expect) get folded in so they stop being separate installs. That consolidation is deliberate. With Expect, having a few separate tools left people unsure what to install and when, and that confusion is a big part of what hurt adoption.
8+
9+
Installation stays the same. You install it with `npx react-doctor` and call it as `/react`. The package name and the brand stay React Doctor — so we keep the GitHub stars, the install command people already know, and the [react.doctor](https://www.react.doctor) domain — but the command itself is the shorter `/react`, since that is what you reach for while writing React.
10+
11+
## What it does
12+
13+
There are really two kinds of work it handles. Most of it runs instantly off the code itself, and the parts that need a browser only spin one up when you actually ask for them.
14+
15+
- **Finding and fixing problems** (`doctor`, or `debug`, `audit`, `review`): it reads your code for the score and the 502 rules, profiles speed when you ask (`perf`), or opens a browser to reproduce a bug (`debug`).
16+
- **Building and improving the user interface, or UI** (`design`, or `dev`): it writes or refines UI and reviews it against a screenshot, and `grab` lets you point at an element on screen to get its file, line, and component tree.
17+
18+
When something does need a browser, it connects to the Chrome you already have open over the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) (CDP), and only downloads its own copy of Chrome if nothing is running. That one detail solves the biggest headache Expect had, because logins and cookies work on their own when it is your real session.
19+
20+
The browser part is our own, slimmed down from Expect. It keeps the page open between commands, runs scripts in a sandbox, and carries the accessibility check, the speed trace, the page snapshot, and the network log. We left out the things we do not need, like the video recording, the terminal UI, and the test runner. Since it runs from the command line and the open page holds the state, there is no separate server to keep alive.
21+
22+
## How it picks what to do
23+
24+
We let the model choose which job to run by reading the skill, the same way it already routes to other skills, rather than matching keywords. So `/react` on its own lets it decide, naming a job like `/react perf` forces that one, and when the request is genuinely unclear the skill just asks.
25+
26+
For the common cases it follows a rough guide:
27+
28+
| You say or change | Job |
29+
| ------------------------------------------------------- | ------------------- |
30+
| "review", "before commit", "clean up", or changed files | doctor (reads code) |
31+
| "slow", "laggy", "re-rendering" | perf |
32+
| "broken", "crashes", "not working" | debug (browser) |
33+
| "looks off", "polish", a pasted element or screenshot | design |
34+
| unclear | ask |
35+
36+
We keep this judgment with the model instead of a fixed keyword list, because keyword matching only gets worse as models get better at reading what you actually meant.
37+
38+
## Where the instructions live
39+
40+
The skill itself stays small, in the same spirit as the [writing-guidelines](.agents/skills/writing-guidelines/SKILL.md) skill. The `SKILL.md` holds only the ten always-on rules and the short routing guide, and each job pulls its full instructions fresh from a web address when it runs:
41+
42+
```bash
43+
curl --fail --silent --show-error \
44+
https://www.react.doctor/prompts/debug.md
45+
```
46+
47+
The per-rule fix recipes load the same way, for example `https://www.react.doctor/prompts/rules/react-builtins/exhaustive-deps.md`. Editing one of these files updates every user on their next run, with no reinstall, and the `/react` triage flow already works exactly like this.
48+
49+
Because react-doctor is open source, these instruction files live in the repo and download straight from GitHub (`raw.githubusercontent.com`), the same approach writing-guidelines uses, and the `react.doctor/prompts` links are just a stable alias for those same files. A copy ships inside the package as a fallback for when the download fails or the network is blocked.
50+
51+
The reason for the split is that the ten rules belong inside the skill, since they apply to every edit, while the longer job instructions belong online so we can change them without shipping a new version.
52+
53+
## Running in the background
54+
55+
By default the skill works without anyone calling it. When the agent finishes a task it looks only at the changed lines, and it stays quiet unless it finds one to three issues it is genuinely confident about, mentioning each one once.
56+
57+
The browser jobs (perf, debug, design review) never start on their own. They only run when you or the agent asks, because they are slower and need a live page, and firing them on every change would just be noise. Noise is what gets a skill turned off.
58+
59+
## The always-on React rules
60+
61+
The skill ships with ten rules that shape how the agent writes React on every edit. We keep the list at ten on purpose, since the real value is in the browser jobs rather than a long rulebook:
62+
63+
1. Derive state, do not duplicate it.
64+
2. Skip effects for values you can compute while rendering and for handling events.
65+
3. Compose components instead of piling on boolean props.
66+
4. Lift state only as far as it needs to go.
67+
5. Keep one source of truth.
68+
6. Render without side effects.
69+
7. Use stable keys, never the array index.
70+
8. Fetch independent data in parallel.
71+
9. Skip manual memoization and let the compiler handle it.
72+
10. Handle the loading, error, and empty states.
73+
74+
## The debug job
75+
76+
The debug job is [debug-agent](https://github.com/millionco/debug-agent), the runtime-evidence debugging skill, wired into the browser part. Rather than guess a fix from the code, the agent writes down a few hypotheses, drops small [NDJSON](https://github.com/ndjson/ndjson-spec) logs into the suspect spots, reproduces the bug, and only fixes once the logs prove the cause — then verifies against the logs and removes the instrumentation. A short-lived local server (`npx debug-agent`) collects the logs; the browser part drives the reproduction and reads what actually rendered. We reuse debug-agent here instead of writing our own loop because it already solves the hard part — making the agent fix with evidence, not vibes — and it is our own tool, so the two stay in step.
77+
78+
## The design job
79+
80+
We write the design job ourselves, treating [React UI](../brain/react-ui/SKILL.md), [ui.sh](https://ui.sh), [Impeccable](../brain/impeccable/SKILL.md), and the [design reference](../brain/design-reference.md) as source material rather than dependencies. The important part is that a review opens the page, takes a screenshot, and reports things it can actually measure, like contrast ratio, line length, spacing, and tap-target size, instead of offering opinions. Those measured findings are the part a smarter model cannot match just by reading code, whereas plain taste advice is. The `dev` name runs the live loop, where you point at an element and improve it as you build.
81+
82+
## CI: where it goes team-wide
83+
84+
In CI the first version stays code-only: the scan, the score, a pinned pull request comment, and inline suggestions. The browser jobs stay on your machine, and profiling in CI comes later, since it needs the app booted and a stable baseline, and it is really the "prove the impact to my manager" feature.
85+
86+
The local skill earns the habit one developer at a time, and CI is how that becomes the whole team's default on every pull request. Both are free. The path from one to the other is the prompt already in the installer that offers to add the GitHub Action, shown once per repo after the local skill has shown its worth. How we make money is a separate decision, not a paywall on CI.
87+
88+
## What we measure
89+
90+
Three numbers carry the story:
91+
92+
- **Habit**: how often the agent accepts and lands a fix the background check surfaced, plus weekly active repos.
93+
- **Conversion**: the share of local users who turn on the CI check, which tells us whether personal use spreads to the whole team.
94+
- **Retention**: whether teams keep the CI check on over time, which is the one that matters most.
95+
96+
Alongside that, a short "very sad, mid, not sad" survey runs in the command line for a sample of users.
97+
98+
## What to build, in order
99+
100+
We build all of this and land it as one pull request, rather than merging anything piecemeal first. [Pull request #811](https://github.com/millionco/react-doctor/pull/811) is the starting point for the profiler, not something we merge on its own. The order we build it in:
101+
102+
1. Take the profiler from pull request #811 as the starting point and rebuild it as the perf job. It is the template for every browser job.
103+
2. Prove the browser part works: connect to a running Chrome, keep the page open across commands, run sandboxed scripts, and inject the [React DevTools](https://react.dev/learn/react-developer-tools) hooks. Everything else depends on this, so it goes first.
104+
3. Expand the `/react` skill, with the ten rules plus the routing guide, under 200 lines.
105+
4. Turn on the background check, code-only and on by default.
106+
5. Add grab on top of the browser part, and wire the debug job to the [debug-agent](https://github.com/millionco/debug-agent) loop driving that same browser.
107+
6. Write the design job with measured review.
108+
7. Deprecate React Grab, leaving a shim so current users do not break.
109+
110+
## What could go wrong
111+
112+
- **The browser part**: if connecting to Chrome, keeping the page open, and injecting the DevTools hooks do not compose, the whole browser story is shaky, so we test it before building on it.
113+
- **Background noise**: on by default with a loose threshold is how you get uninstalled. We ship it nearly silent and loosen it only once the fix-acceptance data says it is safe.
114+
- **Conversion**: if people love the local skill but never turn it on in CI, it stays a personal tool and never reaches the team. Better to learn that early and cheaply.
115+
- **Design**: if measured review turns out no better than the model's own taste, we cut design from the first version.

packages/browser/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@react-doctor/browser",
3+
"version": "0.5.4",
4+
"private": true,
5+
"description": "Internal: the in-house browser core for React Doctor. Attaches to a running Chrome over CDP (launches one only as a fallback), keeps the page open across commands, and exposes eval, snapshot, screenshot, audit, and grab for the debug/design/grab jobs.",
6+
"license": "MIT",
7+
"type": "module",
8+
"sideEffects": false,
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack",
17+
"typecheck": "tsc --noEmit",
18+
"test": "vp test run"
19+
},
20+
"dependencies": {
21+
"playwright-core": "^1.49.1"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.6.0"
25+
},
26+
"engines": {
27+
"node": "^20.19.0 || >=22.13.0"
28+
}
29+
}

packages/browser/src/connect.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { chromium, type Browser } from "playwright-core";
2+
import { CONNECT_TIMEOUT_MS, DEFAULT_CDP_ENDPOINT } from "./constants.js";
3+
import type { BrowserConnectOptions } from "./types.js";
4+
5+
export interface BrowserConnection {
6+
browser: Browser;
7+
// True when we attached to an already-running Chrome over CDP. Closing the
8+
// handle then only disconnects; it never kills the user's browser.
9+
attached: boolean;
10+
}
11+
12+
// Attach to the Chrome the user already has open (CDP), falling back to
13+
// launching the system Chrome when no debuggable instance is reachable.
14+
export const connectToBrowser = async (
15+
options: BrowserConnectOptions = {},
16+
): Promise<BrowserConnection> => {
17+
const endpoint = options.cdpEndpoint ?? DEFAULT_CDP_ENDPOINT;
18+
try {
19+
const browser = await chromium.connectOverCDP(endpoint, { timeout: CONNECT_TIMEOUT_MS });
20+
return { browser, attached: true };
21+
} catch (attachError) {
22+
if (options.launch === false) {
23+
throw new Error(
24+
`Could not attach to Chrome at ${endpoint}. Start Chrome with --remote-debugging-port=${new URL(endpoint).port || "9222"}, or allow launching a browser.`,
25+
{ cause: attachError },
26+
);
27+
}
28+
const browser = await chromium.launch({
29+
channel: options.channel ?? "chrome",
30+
headless: false,
31+
});
32+
return { browser, attached: false };
33+
}
34+
};

packages/browser/src/constants.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Default Chrome DevTools Protocol endpoint. A user opts their browser in by
2+
// launching Chrome with `--remote-debugging-port=9222`; we attach to that.
3+
export const DEFAULT_CDP_PORT = 9222;
4+
export const DEFAULT_CDP_ENDPOINT = `http://127.0.0.1:${DEFAULT_CDP_PORT}`;
5+
6+
// How long to wait for a CDP attach before falling back to launching Chrome.
7+
export const CONNECT_TIMEOUT_MS = 5_000;
8+
9+
// How long a single page navigation may take before we give up.
10+
export const NAVIGATION_TIMEOUT_MS = 30_000;
11+
12+
// React attaches the fiber for a DOM node under a per-render key whose name
13+
// begins with these prefixes (React 16+). We scan an element's own keys to find
14+
// it rather than hard-coding the random suffix.
15+
export const REACT_FIBER_KEY_PREFIX = "__reactFiber$";
16+
export const REACT_INTERNAL_INSTANCE_KEY_PREFIX = "__reactInternalInstance$";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { GrabComponent, GrabResult } from "../types.js";
2+
3+
// Assemble the owner chain a grab pulled from the page (nearest component first)
4+
// into the result the CLI prints. The page hands us a flat, serializable chain;
5+
// the structure and formatting live here so they stay pure and testable.
6+
export const buildGrabResult = (ownerChain: GrabComponent[]): GrabResult => {
7+
if (ownerChain.length === 0) {
8+
return {
9+
component: { name: "Unknown", source: null },
10+
path: [],
11+
approximate: true,
12+
};
13+
}
14+
const [component, ...path] = ownerChain;
15+
return {
16+
component,
17+
path,
18+
approximate: component.source === null,
19+
};
20+
};
21+
22+
const formatComponentLine = (component: GrabComponent): string => {
23+
if (component.source === null) return component.name;
24+
const { fileName, lineNumber } = component.source;
25+
return `${component.name} ${fileName}:${lineNumber}`;
26+
};
27+
28+
// Render the result as the indented owner tree shown in the grab playbook.
29+
export const formatGrabResult = (result: GrabResult): string => {
30+
const lines = [formatComponentLine(result.component)];
31+
result.path.forEach((owner, index) => {
32+
const indent = " ".repeat(index + 1);
33+
lines.push(`${indent}${formatComponentLine(owner)}`);
34+
});
35+
if (result.approximate) {
36+
lines.push("");
37+
lines.push(
38+
"(approximate: no React debug source — run a dev build with the JSX source transform for exact lines)",
39+
);
40+
}
41+
return lines.join("\n");
42+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { FiberSource, GrabComponent } from "../types.js";
2+
3+
// Runs inside the page (serialized by Playwright), so it must be self-contained:
4+
// no imports, no closure over module constants. Given a selector (or the element
5+
// a picker stored on `window.__REACT_DOCTOR_GRAB_TARGET`), it finds the React
6+
// fiber on the DOM node and walks the `_debugOwner` chain into a flat, plain
7+
// array. The Node side assembles and formats it (build-grab-result.ts).
8+
export const grabInPage = (selector: string | null): GrabComponent[] => {
9+
interface PageFiber {
10+
type?: unknown;
11+
_debugSource?: { fileName: string; lineNumber: number; columnNumber?: number } | null;
12+
_debugOwner?: PageFiber | null;
13+
}
14+
15+
const win = window as unknown as { __REACT_DOCTOR_GRAB_TARGET?: Element | null };
16+
const target: Element | null = selector
17+
? document.querySelector(selector)
18+
: (win.__REACT_DOCTOR_GRAB_TARGET ?? document.activeElement);
19+
if (!target) return [];
20+
21+
const fiberKey = Object.keys(target).find(
22+
(key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"),
23+
);
24+
if (!fiberKey) return [];
25+
const fiber = (target as unknown as Record<string, PageFiber | undefined>)[fiberKey];
26+
if (!fiber) return [];
27+
28+
const nameOf = (type: unknown): string => {
29+
if (!type) return "Unknown";
30+
if (typeof type === "string") return type;
31+
if (typeof type === "function") {
32+
const fn = type as { displayName?: string; name?: string };
33+
return fn.displayName || fn.name || "Anonymous";
34+
}
35+
if (typeof type === "object") {
36+
const obj = type as {
37+
displayName?: string;
38+
render?: { displayName?: string; name?: string };
39+
type?: unknown;
40+
};
41+
return (
42+
obj.displayName ||
43+
(obj.render && (obj.render.displayName || obj.render.name)) ||
44+
nameOf(obj.type) ||
45+
"Anonymous"
46+
);
47+
}
48+
return "Unknown";
49+
};
50+
51+
const toSource = (node: PageFiber): FiberSource | null => {
52+
const debug = node._debugSource;
53+
if (!debug) return null;
54+
return {
55+
fileName: debug.fileName,
56+
lineNumber: debug.lineNumber,
57+
columnNumber: debug.columnNumber ?? 0,
58+
};
59+
};
60+
61+
const chain: GrabComponent[] = [];
62+
let owner: PageFiber | null | undefined = fiber._debugOwner;
63+
while (owner) {
64+
chain.push({ name: nameOf(owner.type), source: toSource(owner) });
65+
owner = owner._debugOwner;
66+
}
67+
if (chain.length === 0) {
68+
chain.push({ name: nameOf(fiber.type), source: toSource(fiber) });
69+
}
70+
return chain;
71+
};

packages/browser/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { BrowserSession } from "./session.js";
2+
export { connectToBrowser } from "./connect.js";
3+
export type { BrowserConnection } from "./connect.js";
4+
export { buildGrabResult, formatGrabResult } from "./grab/build-grab-result.js";
5+
export { grabInPage } from "./grab/grab-in-page.js";
6+
export * from "./types.js";
7+
export * from "./constants.js";

0 commit comments

Comments
 (0)