From a7d5a6947044976f0a76814f13c314504727c4d1 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 19 Jun 2026 01:10:39 -0700 Subject: [PATCH 01/38] feat(react): /react-doctor umbrella skill, in-house browser core, and debug/perf runtime jobs Adds the @react-doctor/browser package (CDP attach-and-launch over playwright-core: open, eval, snapshot, screenshot, axe-core audit, console/network capture, LoAF-based perf with per-script attribution, single-load report, viewport emulation) and the React DevTools profiler harness injected as a document-start init script (window.__REACT_PERF__), the @react-doctor/debug NDJSON logging server for the debug job (per-project lock/log scoping, session dedup, daemon/json modes), the `browser` and `debug serve` CLI commands, and the expanded /react-doctor skill (perf, debug, design references) installed to both skills/ and .agents/skills/. --- .agents/skills/react-doctor/SKILL.md | 71 +++- .../skills/react-doctor/references/debug.md | 87 +++++ .../skills/react-doctor/references/design.md | 52 +++ .../skills/react-doctor/references/explain.md | 51 ++- .../react-doctor/references/performance.md | 55 +++ .changeset/react-browser-debug-skill.md | 5 + packages/browser/package.json | 32 ++ packages/browser/src/connect.ts | 45 +++ packages/browser/src/constants.ts | 50 +++ packages/browser/src/index.ts | 4 + packages/browser/src/launch.ts | 101 ++++++ packages/browser/src/perf-observer.ts | 116 ++++++ .../devtools/collect-profiling-export.ts | 63 ++++ .../devtools/install-backend.ts | 14 + .../browser/src/react-profiler/harness.ts | 30 ++ packages/browser/src/react-profiler/inject.ts | 6 + .../types/devtools-inline-backend.d.ts | 5 + .../react-profiler/types/profiling-export.ts | 49 +++ .../react-profiler/types/react-devtools.ts | 22 ++ packages/browser/src/session.ts | 329 ++++++++++++++++++ packages/browser/src/types.ts | 62 ++++ packages/browser/src/utils/cdp-port.ts | 11 + packages/browser/src/utils/delay.ts | 2 + .../browser/src/utils/is-loopback-endpoint.ts | 8 + packages/browser/tests/cdp-port.test.ts | 14 + packages/browser/tests/connect.test.ts | 11 + .../collect-profiling-export.test.ts | 78 +++++ packages/browser/tsconfig.json | 12 + packages/browser/vite.config.ts | 51 +++ packages/debug/package.json | 26 ++ packages/debug/src/constants.ts | 23 ++ packages/debug/src/index.ts | 3 + packages/debug/src/server.ts | 221 ++++++++++++ packages/debug/src/types.ts | 38 ++ packages/debug/src/utils/ping-server.ts | 33 ++ .../debug/src/utils/resolve-log-directory.ts | 20 ++ packages/debug/src/utils/server-lock.ts | 38 ++ packages/debug/tests/server.test.ts | 71 ++++ packages/debug/tsconfig.json | 9 + packages/debug/vite.config.ts | 18 + packages/react-doctor/package.json | 5 + .../react-doctor/src/cli/commands/browser.ts | 238 +++++++++++++ .../react-doctor/src/cli/commands/debug.ts | 110 ++++++ packages/react-doctor/src/cli/index.ts | 123 +++++++ .../react-doctor/src/cli/utils/constants.ts | 7 + .../src/cli/utils/parse-viewport.ts | 17 + .../src/cli/utils/strip-unknown-cli-flags.ts | 27 ++ .../react-doctor/tests/parse-viewport.test.ts | 16 + .../tests/strip-unknown-cli-flags.test.ts | 46 +++ packages/react-doctor/vite.config.ts | 26 ++ pnpm-lock.yaml | 68 ++++ skills/react-doctor/SKILL.md | 71 +++- skills/react-doctor/references/debug.md | 87 +++++ skills/react-doctor/references/design.md | 52 +++ skills/react-doctor/references/explain.md | 49 ++- skills/react-doctor/references/performance.md | 55 +++ 56 files changed, 2854 insertions(+), 79 deletions(-) create mode 100644 .agents/skills/react-doctor/references/debug.md create mode 100644 .agents/skills/react-doctor/references/design.md create mode 100644 .agents/skills/react-doctor/references/performance.md create mode 100644 .changeset/react-browser-debug-skill.md create mode 100644 packages/browser/package.json create mode 100644 packages/browser/src/connect.ts create mode 100644 packages/browser/src/constants.ts create mode 100644 packages/browser/src/index.ts create mode 100644 packages/browser/src/launch.ts create mode 100644 packages/browser/src/perf-observer.ts create mode 100644 packages/browser/src/react-profiler/devtools/collect-profiling-export.ts create mode 100644 packages/browser/src/react-profiler/devtools/install-backend.ts create mode 100644 packages/browser/src/react-profiler/harness.ts create mode 100644 packages/browser/src/react-profiler/inject.ts create mode 100644 packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts create mode 100644 packages/browser/src/react-profiler/types/profiling-export.ts create mode 100644 packages/browser/src/react-profiler/types/react-devtools.ts create mode 100644 packages/browser/src/session.ts create mode 100644 packages/browser/src/types.ts create mode 100644 packages/browser/src/utils/cdp-port.ts create mode 100644 packages/browser/src/utils/delay.ts create mode 100644 packages/browser/src/utils/is-loopback-endpoint.ts create mode 100644 packages/browser/tests/cdp-port.test.ts create mode 100644 packages/browser/tests/connect.test.ts create mode 100644 packages/browser/tests/react-profiler/collect-profiling-export.test.ts create mode 100644 packages/browser/tsconfig.json create mode 100644 packages/browser/vite.config.ts create mode 100644 packages/debug/package.json create mode 100644 packages/debug/src/constants.ts create mode 100644 packages/debug/src/index.ts create mode 100644 packages/debug/src/server.ts create mode 100644 packages/debug/src/types.ts create mode 100644 packages/debug/src/utils/ping-server.ts create mode 100644 packages/debug/src/utils/resolve-log-directory.ts create mode 100644 packages/debug/src/utils/server-lock.ts create mode 100644 packages/debug/tests/server.test.ts create mode 100644 packages/debug/tsconfig.json create mode 100644 packages/debug/vite.config.ts create mode 100644 packages/react-doctor/src/cli/commands/browser.ts create mode 100644 packages/react-doctor/src/cli/commands/debug.ts create mode 100644 packages/react-doctor/src/cli/utils/parse-viewport.ts create mode 100644 packages/react-doctor/tests/parse-viewport.test.ts create mode 100644 skills/react-doctor/references/debug.md create mode 100644 skills/react-doctor/references/design.md create mode 100644 skills/react-doctor/references/performance.md diff --git a/.agents/skills/react-doctor/SKILL.md b/.agents/skills/react-doctor/SKILL.md index 332a47f8b..e85b926fa 100644 --- a/.agents/skills/react-doctor/SKILL.md +++ b/.agents/skills/react-doctor/SKILL.md @@ -1,26 +1,61 @@ --- name: react-doctor -description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. -version: "1.2.0" +description: Use when writing, finishing, or committing React or React Native code, when the user types `/react-doctor`, or when they ask to scan, triage, lint, profile performance, debug a UI in the browser, or review design and accessibility. Covers lint, accessibility, performance, bundle size, and architecture. +version: "1.5.0" --- # React Doctor -Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. +One skill that makes your agent good at React. It writes better React by default, checks your changes in the background, and opens a real browser to profile performance, reproduce bugs, and review design. -## After making React code changes: +## Baseline rules (always on) -Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress. +Apply these on every React edit, before any tool runs. They shape how you write code, not only what you flag: -If the score dropped, fix the regressions before committing. +1. Derive state during render, don't duplicate it in another `useState`. +2. Skip effects for values you can compute while rendering and for logic that belongs in an event handler. +3. Compose components instead of piling on boolean props. +4. Lift state only as far as it needs to go, no higher. +5. Keep one source of truth for each piece of state. +6. Render without side effects; keep the render pass pure. +7. Use stable keys in lists, never the array index. +8. Fetch independent data in parallel, not in a waterfall. +9. Skip manual `useMemo`, `useCallback`, and `memo`; let the React Compiler handle it. +10. Handle the loading, error, and empty states, not only the happy path. -## For general cleanup or code improvement: +## Routing -Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings. +`/react-doctor` picks the job from what you're doing. Name a job (`/react-doctor perf`) to force it. When the request is genuinely unclear, ask which one rather than guessing. -## /doctor — full local triage workflow +| Signal | Job | What it does | +| ------------------------------------------------------- | ---------- | ------------------------------- | +| "review", "before commit", "clean up", or changed files | **doctor** | static scan plus 0 to 100 score | +| "slow", "laggy", "janky", "re-rendering" | **perf** | React DevTools profiler harness | +| "broken", "crashes", "doesn't work" in the UI | **debug** | reproduce in a real browser | +| "looks off", "polish", a screenshot or pasted element | **design** | measured UI review | -When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: +doctor runs from code alone, so it is the one that fires in the background. The browser jobs (perf, debug, design) need a live page and are slower, so they run only when asked. + +## Which browser to drive + +debug, design, and perf need a real Chrome. Two ways to get one: + +1. **A browser MCP already in your tools.** Prefer [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) (`chrome-devtools`) or similar for console, network, and snapshots. It adds full performance traces and Lighthouse on top. +2. **The bundled `react-doctor browser` command.** Attaches to the Chrome you already have open over the Chrome DevTools Protocol, and launches a dedicated persistent one only as a fallback. It covers `open`, `eval`, `snapshot`, `screenshot`, `console`, `network`, an axe-core `audit`, and `perf` (long animation frames with per-script attribution). + +It is the same Chrome either way, so the playbooks apply to both: `browser open`, `eval`, `snapshot`, and `screenshot` map onto the MCP's `navigate_page`, `evaluate_script`, `take_snapshot`, and `take_screenshot`. + +## doctor: scan and triage + +After making React changes, run a regression check and confirm the score did not drop: + +```bash +npx react-doctor@latest --verbose --scope changed +``` + +If the score dropped, fix the regressions before committing. For a cleanup of the whole codebase, drop `--scope changed` (the default is `--scope full`) and fix by severity: errors first, then warnings. + +When the user types `/react-doctor`, `/doctor`, says "run react doctor", or asks for a full triage or cleanup pass (not a regression check), fetch the canonical local-triage playbook and follow every step in it: ```bash curl --fail --silent --show-error \ @@ -28,13 +63,23 @@ curl --fail --silent --show-error \ https://www.react.doctor/prompts/react-doctor-agent.md ``` -The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. +The playbook is the single source of truth: a scan, filter, triage, fix, validate loop that edits the working tree directly and never commits or opens PRs. Updating the prompt at its source updates every agent on its next fetch, no reinstall needed. Pair it with the per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the reviewer-tested recipe. + +## perf: profile performance + +When the user reports jank, slow interactions, dropped frames, excessive re-renders, or asks to profile or optimize render performance, read [references/performance.md](references/performance.md) and follow it. It runs an evidence-driven profile, analyze, fix, re-profile loop against the real React DevTools profiler export, never guessing from code alone. + +## debug: reproduce in a real browser + +When the user says something is broken, crashes, throws, or behaves wrong in the running app, read [references/debug.md](references/debug.md) and follow it. It runs the [debug-agent](https://github.com/millionco/debug-agent) loop: generate hypotheses, instrument the code with runtime NDJSON logs, reproduce the bug in the live browser, and fix only once the logs prove the cause. + +## design: review and improve UI -Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. +When the user wants to build, polish, or review an interface ("looks off", "make this nicer", a pasted screenshot or element), read [references/design.md](references/design.md) and follow it. It opens the page, takes a screenshot, and reports what it can measure (contrast, line length, spacing, tap-target size), not only taste. ## Configuring or explaining rules -When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). +When the user wants to understand a rule, disagrees with one, or wants to disable or tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`. ## Command diff --git a/.agents/skills/react-doctor/references/debug.md b/.agents/skills/react-doctor/references/debug.md new file mode 100644 index 000000000..afd5431e2 --- /dev/null +++ b/.agents/skills/react-doctor/references/debug.md @@ -0,0 +1,87 @@ +# Debugging with runtime evidence + +Reproduce and fix UI bugs with runtime evidence, never by guessing from code alone. Use this when the user says something is broken, crashes, throws, hangs, or behaves wrong in the running app. + +This is the [debug-agent](https://github.com/millionco/debug-agent) loop, built into React Doctor: hypothesize, instrument with logs, reproduce, analyze the logs, fix only once the logs prove the cause, verify, clean up. + +## 0. Start the logging server (before any instrumentation) + +The server is long-running. Start it once and keep it up for the whole session. `--daemon` prints the server info and returns, leaving the server running in the background: + +```bash +npx react-doctor debug serve --daemon +``` + +It prints one JSON line. Capture and remember: + +- `endpoint`: POST your logs here from JS or TS at runtime +- `logPath`: the NDJSON log file you read after each run +- `sessionId`: include it in every log payload + +The server is idempotent: a second start returns the running server's info. If it fails to start, stop and tell the user. Do not instrument without it. + +## 1. Generate hypotheses + +Write 3 to 5 precise hypotheses about why the bug happens: a thrown error in a specific component, a failed or duplicated request, a null or undefined access, a state update after unmount, a missing loading or error branch. Aim for more, not fewer. Each hypothesis gets an id (A, B, C, …). + +## 2. Instrument the code + +Add 2 to 6 logs (never more than 10) at the points that confirm or reject each hypothesis: function entry and exit, values before and after a critical operation, which branch ran. In JS or TS, POST to the server `endpoint`: + +```js +// #region debug log +fetch("ENDPOINT", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: "SESSION_ID", + hypothesisId: "A", + location: "cart.tsx:42", + message: "cart total before render", + data: { total }, + timestamp: Date.now(), + }), +}).catch(() => {}); +// #endregion +``` + +Wrap every debug log in `// #region debug log` and `// #endregion` so cleanup later is deterministic. Each log maps to at least one `hypothesisId`. Never log secrets or PII. + +## 3. Reproduce + +Clear the log file (`DELETE` the file at `logPath`) before each run, then trigger the exact behavior the user described: + +- **Browser bugs:** drive the repro with whatever controls a live Chrome. The bundled browser core attaches to the Chrome you already have open over the Chrome DevTools Protocol, so the real session, logins, and cookies come along. If nothing debuggable is running, it launches a dedicated persistent Chrome (its own profile) that later commands reattach to, so the flow below works either way. To drive your real logged-in session, open Chrome with `--remote-debugging-port=9222` first and it attaches to that instead. `browser console` and `browser network` hand you the runtime console (with uncaught errors) and the request waterfall with failures flagged, often the evidence you need before instrumenting at all. To get the whole picture in one pass, `browser report` captures console, network, performance, and accessibility in a single page load instead of reloading once per command; prefer it over running the four separately. If [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) (`chrome-devtools`) is in your tools, it also covers this and adds performance traces and Lighthouse. + +```bash +npx react-doctor browser open http://localhost:3000 # attach + open the page +npx react-doctor browser report http://localhost:3000 # console + network + perf + a11y in one load +npx react-doctor browser console http://localhost:3000 # console output + uncaught errors +npx react-doctor browser network http://localhost:3000 # request waterfall, failures flagged +npx react-doctor browser snapshot # what rendered, by role + name +npx react-doctor browser eval 'page.getByRole("button", { name: "Checkout" }).click()' +npx react-doctor browser eval 'page.evaluate(() => document.title)' # raw DOM when you need it +``` + +`snapshot` and `eval` are a pair. `snapshot` lists the rendered elements by role and accessible name. `eval` runs an expression with the Playwright `page` in scope, so you act on what you saw using Playwright's own selectors: `page.locator("text=Login").click()`, `page.getByRole(...)`, `page.fill(...)`, `page.waitForSelector(...)`. For raw DOM, reach through `page.evaluate(() => …)`. No separate ref scheme to track. + +- **Backend or CLI bugs:** write and run a small repro script (Node, shell) yourself. +- Otherwise ask the user for numbered steps, and remind them to restart any app or service whose instrumented files are bundled or cached. + +Reuse the same repro pathway for every iteration. + +## 4. Analyze the logs + +Read the NDJSON at `logPath`. Mark each hypothesis CONFIRMED, REJECTED, or INCONCLUSIVE, citing the specific log lines. If the file is empty, the repro likely did not run the instrumented path, so try again. If every hypothesis is rejected, revert the rejected code changes, generate new hypotheses from a different subsystem, and add more instrumentation. + +## 5. Fix, only with proof + +Apply the smallest change that addresses the proven cause. Cross-check it against the baseline rules in `SKILL.md` (derive don't duplicate, effects, single source of truth). Do not remove the instrumentation yet. Never use `setTimeout` or `sleep` as a fix. + +## 6. Verify + +Clear the log file, re-run the same reproduction (tag the logs `runId:"post-fix"` if helpful), and compare before and after with cited lines. Re-run a couple of times to rule out races. No fix is confirmed without log proof. + +## 7. Clean up + +Once verified, search every file for `#region debug log`, delete each block through its `#endregion`, grep again to confirm none remain, and `git diff` to confirm only the intentional fix is left. diff --git a/.agents/skills/react-doctor/references/design.md b/.agents/skills/react-doctor/references/design.md new file mode 100644 index 000000000..74f50928f --- /dev/null +++ b/.agents/skills/react-doctor/references/design.md @@ -0,0 +1,52 @@ +# Reviewing and improving UI + +Improve interfaces with measured evidence from the rendered page, not taste alone. Use this when the user wants to build, polish, or review a UI: "looks off", "make this nicer", or a pasted screenshot. + +The value here is what a screenshot and the live DOM let you measure that reading code cannot: contrast ratios, line length, the spacing scale, and tap-target size. Lead with those, then apply craft. + +## Review against the live page + +```bash +npx react-doctor browser open http://localhost:3000 +npx react-doctor browser screenshot --out review.png # what the user actually sees +npx react-doctor browser audit # axe-core: contrast, names, landmarks +``` + +Review responsive breakpoints with `--viewport WIDTHxHEIGHT` (for example `--viewport 390x844` for a phone) on `screenshot`, `snapshot`, `audit`, or `perf`. It emulates the size for that one command via a CDP override, so it never resizes your real browser window: + +```bash +npx react-doctor browser screenshot --viewport 390x844 --out mobile.png +``` + +Look at the screenshot, then measure specifics with `eval` (computed styles, bounding boxes, color values) to get objective numbers rather than opinions: + +```bash +npx react-doctor browser eval 'page.evaluate(() => getComputedStyle(document.querySelector("button")).fontSize)' +``` + +`browser audit` runs axe-core against the live page and reports accessibility violations (color contrast, missing button or SVG names, heading order, landmarks) with the failing selectors. If [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) (`chrome-devtools`) is in your tools, its `lighthouse_audit` adds performance and best-practice findings on top. Lead with the measured issues; a smarter model cannot dismiss them as opinion. + +## What to check + +Measured, in priority order: + +1. **Contrast**: body text at least 4.5:1, large text at least 3:1. Report the actual ratio. +2. **Tap targets**: interactive elements at least 24 × 24 px (ideally 44 × 44 on touch). +3. **Line length**: body copy roughly 45 to 75 characters per line. +4. **Spacing**: spacing values come from one consistent scale, not ad-hoc px. + +Then craft, drawing on the bundled design rules: + +5. **Type**: one clear hierarchy; avoid default system-only stacks for brand surfaces; consistent line-height. +6. **Color**: a committed palette, not arbitrary hexes; check both light and dark. +7. **Layout**: alignment, rhythm, and a deliberate focal point. +8. **State**: hover, focus-visible, disabled, loading, and empty states exist. + +## The loop + +Build or fix, screenshot, re-audit, compare. Confirm the measured issue you targeted actually moved (the ratio crossed the threshold, the target grew) and that the screenshot looks right before and after. + +## Working rules + +- Always look at the screenshot; do not review UI from JSX alone. +- Report measured findings with their numbers; keep taste suggestions short and clearly separate from the measured ones. diff --git a/.agents/skills/react-doctor/references/explain.md b/.agents/skills/react-doctor/references/explain.md index 722c6f642..18cd0cea2 100644 --- a/.agents/skills/react-doctor/references/explain.md +++ b/.agents/skills/react-doctor/references/explain.md @@ -1,15 +1,12 @@ # Explaining and configuring rules -Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user -wants to understand a rule or change which rules run — not for fixing diagnostics -(that is the main `react-doctor` skill / `/doctor`). +Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user wants to understand a rule or change which rules run, not for fixing diagnostics (that is the main `react-doctor` skill, `/doctor`). -Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", -"stop flagging X", "too noisy", "disable design rules". +Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", "stop flagging X", "too noisy", "disable design rules". ## Workflow -1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). +1. Identify the rule key from the diagnostic (for example `react-doctor/no-array-index-as-key`). 2. Explain it before changing anything: ```bash @@ -17,25 +14,25 @@ npx react-doctor@latest rules explain react-doctor/no-array-index-as-key ``` 3. Pick the narrowest control that matches the user's intent (see decision guide). -4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). +4. Apply it with a `rules` subcommand. It edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting. 5. Validate the change did what they wanted: ```bash -npx react-doctor@latest --verbose --scope changed +npx react-doctor@latest --verbose --diff ``` ## Commands ```bash -npx react-doctor@latest rules list # every rule + its effective severity -npx react-doctor@latest rules list --configured # only what your config changed -npx react-doctor@latest rules list --category Performance # filter by category -npx react-doctor@latest rules explain # why it matters + how to configure -npx react-doctor@latest rules disable # rule never runs -npx react-doctor@latest rules enable # turn back on at its recommended severity -npx react-doctor@latest rules set warn # off | warn | error -npx react-doctor@latest rules category "React Native" off # whole category -npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) +npx react-doctor@latest rules list # every rule + its effective severity +npx react-doctor@latest rules list --configured # only what your config changed +npx react-doctor@latest rules list --category Performance # filter by category +npx react-doctor@latest rules explain # why it matters + how to configure +npx react-doctor@latest rules disable # rule never runs +npx react-doctor@latest rules enable # turn back on at its recommended severity +npx react-doctor@latest rules set warn # off | warn | error +npx react-doctor@latest rules category "React Native" off # whole category +npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) npx react-doctor@latest rules unignore-tag design ``` @@ -43,20 +40,20 @@ Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no ## Decision guide -Match the control to the intent — prefer the narrowest one: +Match the control to the intent, and prefer the narrowest one: -- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". -- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. -- **A disabled-by-default rule they want on** → `rules enable `. -- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. -- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. -- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. +- **User disagrees with one rule, or it is a false positive for them**: `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". +- **Rule is fine but wrong severity**: `rules set warn` or `rules set error`. +- **A disabled-by-default rule they want on**: `rules enable `. +- **A whole area is unwanted** (for example all React Native rules): `rules category "" off`. +- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`): `rules ignore-tag `. +- **Keep it locally but hide from PR comment, score, or CI gate only**: do not disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. -How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. +How the layers combine: `ignore.tags` disables every rule carrying that tag before linting, so a tagged rule stays off even if `rules` or `categories` set it to `warn` or `error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that are not tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. ## Config shape -Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: +Config lives in `doctor.config.ts` (or `.js`, `.mjs`, `.cjs`, `.json`, `.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists (TS and JS edits preserve formatting via magicast) and create `doctor.config.json` when none does, stamping `$schema`: ```ts // doctor.config.ts @@ -69,4 +66,4 @@ export default { ## Educating the user -When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. +When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it: many "bad" rules are catching real issues. diff --git a/.agents/skills/react-doctor/references/performance.md b/.agents/skills/react-doctor/references/performance.md new file mode 100644 index 000000000..e0dc85c79 --- /dev/null +++ b/.agents/skills/react-doctor/references/performance.md @@ -0,0 +1,55 @@ +# Performance engineering (runtime-evidence loop) + +Find and fix jank with runtime evidence, never code reading alone. The primary signal is the long animation frame (LoAF): a frame longer than 50 ms, captured with `PerformanceObserver` and attributed to the exact script that blocked it (its `sourceURL`, `sourceFunctionName`, and how much of that time was synchronous layout). That attribution is what `performance.now()` and reading code cannot give you. Use this when the user reports jank, dropped frames, janky scroll, slow click or typing response, poor INP, slow LCP, or layout shift, or asks to make something faster. + +Same discipline as [debug](./debug.md): hypothesize, capture, analyze the worst frame, fix the top evidence-backed cause, re-capture to verify, repeat. A change that does not make the offending script's frame time drop is not a fix. + +## 1. Hypothesize (3 to 5) + +Why is it slow, and where? Common React causes: unstable callback or object props, a missing `memo` or `useMemo`, a context provider that is too broad, large unvirtualized lists, expensive children re-rendering on every parent commit, or a sync layout read interleaved with writes (layout thrashing). + +## 2. Capture (no app changes) + +`browser perf` arms the LoAF, LCP, and CLS observers, loads the page, watches briefly past load, then reports the worst frames first with per-script attribution: + +```bash +npx react-doctor browser perf http://localhost:3000 # measures the current page if URL omitted +``` + +It drives the same Chrome the other `browser` commands do: your real logged-in session when you started Chrome with `--remote-debugging-port=9222`, otherwise a dedicated persistent one. The output leads with the worst frame (duration plus input-blocking time), then each script that ran in it (time, function name, source, and sync-layout time when present), with LCP and CLS for context. LoAF is Chromium-only; on a quiet page it reports no long frames, which is a result, not a failure. + +To attribute interaction jank (a slow click, scroll, or keypress), drive the repro between load and the read: `browser open`, then `browser eval` the interaction, then `browser perf` with no URL. Without a URL it does not reload; it reads the long frames already buffered in the timeline, so the jank from your interaction is included. + +## 3. Analyze the worst frame first + +The output is already sorted worst-first. The script with the largest duration inside the worst frame is your culprit. If a script's sync-layout time is a large share of its duration, that is layout thrashing: sync reads (`offsetHeight`, `getBoundingClientRect`, `scrollTop`, `getComputedStyle`) interleaved with DOM writes. A minified `sourceURL` is meaningless on its own, so resolve it through your sourcemap. Cite the specific script when you conclude: + +> CONFIRMED: 128 ms frame, script `app.js` `drawSeries` ran 84 ms with 42 ms sync layout. The chart redraw forces layout inside the scroll handler. + +## 4. Zoom into React renders (optional) + +When the worst frame's script is your own React bundle and you need per-component render counts and why each rendered, profile React directly. `browser open` injects the real DevTools profiler before the page loads, so there are no app changes, no Chrome extension, and no manual record or stop: + +```bash +npx react-doctor browser open http://localhost:3000 +``` + +For trustworthy timings, run against React's profiling build (alias `react-dom` to `react-dom/profiling` in your bundler) in a dev or non-prod build. Dev timings work but are inflated. + +Drive it through `browser eval` (the Playwright `page` is in scope). `stop()` returns a JSON profiling export and resolves to `null` when nothing was recorded (a production React build records no profiling data): + +```bash +npx react-doctor browser eval 'page.evaluate(() => window.__REACT_PERF__.start())' +# drive the exact repro with more `browser eval`: page.locator("...").click(), page.keyboard.type("...") +npx react-doctor browser eval 'page.evaluate(() => window.__REACT_PERF__.stop())' +``` + +Aggregate `dataForRoots[].commitData[]`: per fiber, render count and summed `fiberActualDurations` and `fiberSelfDurations` (both `[fiberID, ms]` pairs); `changeDescriptions[fiberID]` for why it rendered (which props, state, hooks, or context changed, plus `isFirstMount` and `didHooksChange`). Everything keys by fiber id; map ids to component names with `dataForRoots[].elementNames` (`[fiberID, name]` pairs). Rank by components that render most often, cost the most self time, or re-render with no meaningful prop change (memoization candidates). + +## 5. Fix, only with proof + +Apply the smallest change that addresses the proven cause. Cross-check it against the baseline rules in [`SKILL.md`](../SKILL.md) (derive don't duplicate, effects, single source of truth). Never fix by wrapping work in `setTimeout`: that defers the work to a later frame, it does not remove it. + +## 6. Verify + +Re-run the same capture and diff before and after: the offending frame and its script time must drop, and no other frame may regress. For the React profiler, re-run the scenario a few times and compare medians (dev timings are noisy; StrictMode double-renders on mount). Never claim a performance win without before-and-after evidence. The profiler leaves nothing behind in your app to clean up; it lives only in the injected browser session. diff --git a/.changeset/react-browser-debug-skill.md b/.changeset/react-browser-debug-skill.md new file mode 100644 index 000000000..ddb941b42 --- /dev/null +++ b/.changeset/react-browser-debug-skill.md @@ -0,0 +1,5 @@ +--- +"react-doctor": minor +--- + +Add the `browser` and `debug` commands behind the unified `/react-doctor` skill. `browser` drives a real Chrome over CDP (attaching to your running session, launching a dedicated persistent profile only as a fallback) for accessibility audits, console/network capture, performance traces with React DevTools profiling, snapshots, and screenshots. `debug` runs an NDJSON logging server the debug job posts runtime evidence to. diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 000000000..f2f9fa373 --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,32 @@ +{ + "name": "@react-doctor/browser", + "version": "0.5.4", + "private": true, + "description": "Internal: React Doctor's browser driver. Attaches to a running Chrome over CDP (or launches one) and keeps the page open across commands, backing the debug and design jobs.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack", + "typecheck": "tsc --noEmit", + "test": "vp test run" + }, + "dependencies": { + "axe-core": "^4.10.2", + "playwright-core": "^1.49.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "esbuild": "^0.25.12", + "react-devtools-inline": "^6.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } +} diff --git a/packages/browser/src/connect.ts b/packages/browser/src/connect.ts new file mode 100644 index 000000000..52d19770c --- /dev/null +++ b/packages/browser/src/connect.ts @@ -0,0 +1,45 @@ +import { chromium, type Browser } from "playwright-core"; +import { CONNECT_TIMEOUT_MS, DEFAULT_CDP_ENDPOINT } from "./constants.js"; +import { launchPersistentChrome } from "./launch.js"; +import type { BrowserConnectOptions } from "./types.js"; +import { cdpPortFromEndpoint } from "./utils/cdp-port.js"; +import { isLoopbackEndpoint } from "./utils/is-loopback-endpoint.js"; + +export interface BrowserConnection { + browser: Browser; + launched: boolean; +} + +// Attach to a debuggable Chrome over CDP. If none is reachable on a local +// endpoint, launch our own persistent, reattachable instance and attach to +// that. We always end up attached over CDP — never holding a launched process +// handle — so the browser survives across commands, the model Chrome DevTools +// MCP uses to keep state. +export const connectToBrowser = async ( + options: BrowserConnectOptions = {}, +): Promise => { + const endpoint = options.cdpEndpoint ?? DEFAULT_CDP_ENDPOINT; + try { + const browser = await chromium.connectOverCDP(endpoint, { timeout: CONNECT_TIMEOUT_MS }); + return { browser, launched: false }; + } catch (attachError) { + // Only launch for a loopback endpoint — we can't spawn Chrome on a remote host. + if (options.launch === false || !isLoopbackEndpoint(endpoint)) { + throw new Error( + `Could not attach to Chrome at ${endpoint}. Start Chrome with --remote-debugging-port=${cdpPortFromEndpoint(endpoint)}, or allow launching a local browser.`, + { cause: attachError }, + ); + } + const reachableEndpoint = await launchPersistentChrome(endpoint); + try { + return { + browser: await chromium.connectOverCDP(reachableEndpoint, { timeout: CONNECT_TIMEOUT_MS }), + launched: true, + }; + } catch (launchedAttachError) { + throw new Error(`Launched Chrome at ${reachableEndpoint} but could not attach to it.`, { + cause: launchedAttachError, + }); + } + } +}; diff --git a/packages/browser/src/constants.ts b/packages/browser/src/constants.ts new file mode 100644 index 000000000..b19da031e --- /dev/null +++ b/packages/browser/src/constants.ts @@ -0,0 +1,50 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Default Chrome DevTools Protocol endpoint. A user opts their browser in by +// launching Chrome with `--remote-debugging-port=9222`; we attach to that. +export const DEFAULT_CDP_PORT = 9222; +export const DEFAULT_CDP_ENDPOINT = `http://127.0.0.1:${DEFAULT_CDP_PORT}`; + +// How long to wait for a CDP attach before falling back to launching Chrome. +export const CONNECT_TIMEOUT_MS = 5_000; + +// How long a single page navigation may take before we give up. +export const NAVIGATION_TIMEOUT_MS = 30_000; + +// Upper bound on waiting for the page to settle (network quiet + fonts) before +// reading or screenshotting it. Best-effort: a page that never goes idle (long +// polling, analytics) hits this cap and we proceed anyway. +export const SETTLE_TIMEOUT_MS = 10_000; + +// Dedicated Chrome profile for the browser we launch ourselves. Mirrors how +// Chrome DevTools MCP keeps a persistent profile out of the user's real one, so +// our launched instance is reattachable across commands and never touches their +// main browsing data. (Chrome also refuses --remote-debugging-port on the +// default profile, so a dedicated dir is required regardless.) +export const LAUNCHED_CHROME_PROFILE_DIRECTORY = join( + homedir(), + ".cache", + "react-doctor", + "chrome-profile", +); + +// How long to wait for a freshly launched Chrome to expose its CDP endpoint, +// and how often to poll for it. +export const LAUNCH_READY_TIMEOUT_MS = 20_000; +export const LAUNCH_POLL_INTERVAL_MS = 100; + +// After the page settles, keep watching for long animation frames this long so +// post-load jank (hydration, late effects) is captured, not just the load burst. +export const PERFORMANCE_OBSERVE_WINDOW_MS = 1_000; + +// Failing element selectors kept per accessibility violation — enough to locate +// the problem without dumping every match on a busy page. +export const MAX_VIOLATION_TARGETS = 5; + +// Built React-profiler init script, relative to the bundle that imports it. +// `react-profiler/inject.ts` is esbuilt into this self-contained IIFE at build +// time (see vite.config.ts); the session injects it via `addInitScript`. The +// path stays valid whether `dist/index.js` runs standalone or is re-bundled +// into the CLI, because the build copies the asset next to each output. +export const REACT_PROFILER_INJECT_FILE = "inject/react-profiler.global.js"; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 000000000..b01289f9b --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1,4 @@ +export { BrowserSession } from "./session.js"; +export { connectToBrowser } from "./connect.js"; +export type { BrowserConnection } from "./connect.js"; +export type * from "./types.js"; diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts new file mode 100644 index 000000000..693e91a70 --- /dev/null +++ b/packages/browser/src/launch.ts @@ -0,0 +1,101 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + LAUNCH_POLL_INTERVAL_MS, + LAUNCH_READY_TIMEOUT_MS, + LAUNCHED_CHROME_PROFILE_DIRECTORY, +} from "./constants.js"; +import { cdpPortFromEndpoint } from "./utils/cdp-port.js"; +import { delay } from "./utils/delay.js"; + +const chromeExecutableCandidates = (): readonly string[] => { + switch (process.platform) { + case "darwin": + return [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ]; + case "win32": + return [ + `${process.env.PROGRAMFILES ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + default: + return [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/opt/google/chrome/chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]; + } +}; + +const resolveChromeExecutable = (): string => { + const candidates = [process.env.CHROME_PATH, ...chromeExecutableCandidates()]; + const executable = candidates.find( + (candidate): candidate is string => typeof candidate === "string" && existsSync(candidate), + ); + if (!executable) { + throw new Error( + "Could not find Google Chrome to launch. Install Chrome, set CHROME_PATH, or start Chrome with --remote-debugging-port and pass --cdp to attach to it.", + ); + } + return executable; +}; + +// Chrome may bind the debug port on IPv4 or IPv6 depending on the host stack — +// notably it falls back to [::1] when 127.0.0.1 is already taken — so probe both +// loopback forms of the endpoint. +const loopbackVariants = (endpoint: string): readonly string[] => { + const variants = new Set([endpoint]); + const url = new URL(endpoint); + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") { + url.hostname = "::1"; + variants.add(url.origin); + } + return [...variants]; +}; + +// Returns the loopback form that actually responded so the caller attaches to +// the right one. +const waitForCdpEndpoint = async (endpoint: string): Promise => { + const candidates = loopbackVariants(endpoint); + const deadline = Date.now() + LAUNCH_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + for (const candidate of candidates) { + try { + const response = await fetch(new URL("/json/version", candidate)); + if (response.ok) return candidate; + } catch {} + } + await delay(LAUNCH_POLL_INTERVAL_MS); + } + throw new Error(`Launched Chrome but it never exposed its debugger at ${endpoint}.`); +}; + +// Detached and unref'd on success so the browser outlives this process and the +// next `browser` command reattaches over CDP — the persistent model Chrome +// DevTools MCP uses to keep state across calls. +export const launchPersistentChrome = async (endpoint: string): Promise => { + const executable = resolveChromeExecutable(); + const args = [ + `--remote-debugging-port=${cdpPortFromEndpoint(endpoint)}`, + `--user-data-dir=${LAUNCHED_CHROME_PROFILE_DIRECTORY}`, + "--no-first-run", + "--no-default-browser-check", + ]; + + const child = spawn(executable, args, { detached: true, stdio: "ignore" }); + // HACK: swallow async spawn errors (e.g. a non-executable binary) so they + // don't crash the CLI as an uncaught exception; waitForCdpEndpoint surfaces + // the failure as an actionable timeout instead. + child.on("error", () => {}); + const reachableEndpoint = await waitForCdpEndpoint(endpoint).catch((error: unknown) => { + child.kill(); + throw error; + }); + child.unref(); + return reachableEndpoint; +}; diff --git a/packages/browser/src/perf-observer.ts b/packages/browser/src/perf-observer.ts new file mode 100644 index 000000000..1fd83fac4 --- /dev/null +++ b/packages/browser/src/perf-observer.ts @@ -0,0 +1,116 @@ +import type { PerformanceReport } from "./types.js"; + +// Runs in the page (via evaluate) and resolves after `windowMs`. Installs fresh +// LoAF / LCP / CLS observers with `buffered: true`, so frames already in the +// performance timeline (a load just navigated to, or an interaction a previous +// command drove) are replayed immediately, while the window catches anything +// that fires next. A reload resets the timeline, so a fresh-load measurement +// always starts clean. For repeated no-reload measurements on the persistent +// page, `buffered: true` would otherwise replay — and re-count — every frame +// from earlier runs, inflating LoAF rows and CLS. So we keep a per-page +// watermark of the latest entry `startTime` already counted (per type) and skip +// anything at or below it: the first run after an interaction still captures its +// frames, a second run sees only what fired since. LoAF fields are not in +// lib.dom, so the casts here are unavoidable. +export const collectPerformanceReport = (windowMs: number): Promise => { + interface ScriptTiming { + sourceURL?: string; + sourceFunctionName?: string; + invokerType?: string; + duration?: number; + forcedStyleAndLayoutDuration?: number; + } + interface LongAnimationFrameEntry { + startTime: number; + duration: number; + blockingDuration?: number; + scripts?: ScriptTiming[]; + } + interface LayoutShiftEntry { + value: number; + hadRecentInput: boolean; + } + interface MutableReport { + longAnimationFrames: PerformanceReport["longAnimationFrames"]; + largestContentfulPaintMs: number | null; + cumulativeLayoutShift: number; + } + + interface CountedEntryWatermark { + longAnimationFrame: number; + layoutShift: number; + } + const WATERMARK_KEY = "__REACT_DOCTOR_PERF_WATERMARK__"; + + return new Promise((resolve) => { + const report: MutableReport = { + longAnimationFrames: [], + largestContentfulPaintMs: null, + cumulativeLayoutShift: 0, + }; + + // Persisted on the page so it survives across no-reload measurements (and is + // wiped by a navigation, which is exactly when we want a clean slate). + const windowScope = window as unknown as Record; + const previousWatermark: CountedEntryWatermark = windowScope[WATERMARK_KEY] ?? { + longAnimationFrame: -1, + layoutShift: -1, + }; + const nextWatermark: CountedEntryWatermark = { ...previousWatermark }; + + const observers: PerformanceObserver[] = []; + const observe = (type: string, onEntry: (entry: PerformanceEntry) => void): void => { + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) onEntry(entry); + }); + observer.observe({ type, buffered: true }); + observers.push(observer); + } catch {} + }; + + observe("long-animation-frame", (entry) => { + if (entry.startTime <= previousWatermark.longAnimationFrame) return; + nextWatermark.longAnimationFrame = Math.max( + nextWatermark.longAnimationFrame, + entry.startTime, + ); + const longAnimationFrame = entry as unknown as LongAnimationFrameEntry; + report.longAnimationFrames.push({ + startTimeMs: Math.round(longAnimationFrame.startTime), + durationMs: Math.round(longAnimationFrame.duration), + blockingDurationMs: Math.round(longAnimationFrame.blockingDuration ?? 0), + scripts: (longAnimationFrame.scripts ?? []).map((scriptTiming) => ({ + sourceUrl: scriptTiming.sourceURL ?? "", + sourceFunctionName: scriptTiming.sourceFunctionName ?? "", + invokerType: scriptTiming.invokerType ?? "", + durationMs: Math.round(scriptTiming.duration ?? 0), + forcedStyleAndLayoutMs: Math.round(scriptTiming.forcedStyleAndLayoutDuration ?? 0), + })), + }); + }); + + observe("largest-contentful-paint", (entry) => { + report.largestContentfulPaintMs = Math.round(entry.startTime); + }); + + observe("layout-shift", (entry) => { + if (entry.startTime <= previousWatermark.layoutShift) return; + nextWatermark.layoutShift = Math.max(nextWatermark.layoutShift, entry.startTime); + const layoutShift = entry as unknown as LayoutShiftEntry; + if (!layoutShift.hadRecentInput) report.cumulativeLayoutShift += layoutShift.value; + }); + + setTimeout(() => { + for (const observer of observers) observer.disconnect(); + windowScope[WATERMARK_KEY] = nextWatermark; + resolve({ + longAnimationFrames: report.longAnimationFrames.sort( + (left, right) => right.durationMs - left.durationMs, + ), + largestContentfulPaintMs: report.largestContentfulPaintMs, + cumulativeLayoutShift: Math.round(report.cumulativeLayoutShift * 1000) / 1000, + }); + }, windowMs); + }); +}; diff --git a/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts b/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts new file mode 100644 index 000000000..84debf5e7 --- /dev/null +++ b/packages/browser/src/react-profiler/devtools/collect-profiling-export.ts @@ -0,0 +1,63 @@ +import type { + ReactProfilerDataExport, + ReactProfilerDataForRootExport, + ReactProfilerRootDataBackend, +} from "../types/profiling-export.js"; +import type { DevtoolsGlobal, ReactRendererInterface } from "../types/react-devtools.js"; + +export const PROFILING_EXPORT_VERSION = 5; + +const collectFiberIds = (root: ReactProfilerRootDataBackend): Set => { + const fiberIds = new Set(); + for (const [fiberId] of root.initialTreeBaseDurations) fiberIds.add(fiberId); + for (const commit of root.commitData) { + for (const [fiberId] of commit.fiberActualDurations) fiberIds.add(fiberId); + for (const [fiberId] of commit.fiberSelfDurations) fiberIds.add(fiberId); + for (const [fiberId] of commit.changeDescriptions ?? []) fiberIds.add(fiberId); + } + return fiberIds; +}; + +// Skipped when the renderer can't resolve names (older React), leaving raw ids. +const resolveElementNames = ( + renderer: ReactRendererInterface, + root: ReactProfilerRootDataBackend, +): Array<[number, string]> => { + const resolve = renderer.getDisplayNameForElementID; + if (!resolve) return []; + const elementNames: Array<[number, string]> = []; + for (const fiberId of collectFiberIds(root)) { + const name = resolve(fiberId); + if (name) elementNames.push([fiberId, name]); + } + return elementNames; +}; + +// Returns null when no renderer is attached or no commits were recorded (e.g. a +// production React build), so `stop()` resolves with null rather than an empty +// object. +export const collectProfilingExport = (target: DevtoolsGlobal): ReactProfilerDataExport | null => { + const renderers = target.__REACT_DEVTOOLS_GLOBAL_HOOK__?.rendererInterfaces; + if (!renderers || renderers.size === 0) return null; + + const dataForRoots: Array = []; + for (const renderer of renderers.values()) { + renderer.stopProfiling(); + // A renderer that attached after `start()` (lazy/code-split root) was never + // profiled, and `getProfilingData` throws for it — skip it rather than lose + // every other renderer's data. + let roots: ReactProfilerRootDataBackend[]; + try { + roots = renderer.getProfilingData().dataForRoots; + } catch { + continue; + } + for (const root of roots) { + dataForRoots.push({ ...root, elementNames: resolveElementNames(renderer, root) }); + } + } + + const hasCommits = dataForRoots.some((root) => root.commitData.length > 0); + if (!hasCommits) return null; + return { version: PROFILING_EXPORT_VERSION, dataForRoots }; +}; diff --git a/packages/browser/src/react-profiler/devtools/install-backend.ts b/packages/browser/src/react-profiler/devtools/install-backend.ts new file mode 100644 index 000000000..9b0cde05e --- /dev/null +++ b/packages/browser/src/react-profiler/devtools/install-backend.ts @@ -0,0 +1,14 @@ +import { initialize as initializeBackend } from "react-devtools-inline/backend"; +import type { DevtoolsGlobal } from "../types/react-devtools.js"; + +// Track per target, not a single boolean, so a second global (iframe/worker) +// still gets its own hook. +const installedTargets = new WeakSet(); + +// MUST run before React loads, otherwise React never connects to the hook and +// no commits are recorded. +export const installReactDevtoolsBackend = (target: DevtoolsGlobal = globalThis): void => { + if (installedTargets.has(target)) return; + initializeBackend(target); + installedTargets.add(target); +}; diff --git a/packages/browser/src/react-profiler/harness.ts b/packages/browser/src/react-profiler/harness.ts new file mode 100644 index 000000000..0c356fb70 --- /dev/null +++ b/packages/browser/src/react-profiler/harness.ts @@ -0,0 +1,30 @@ +import { collectProfilingExport } from "./devtools/collect-profiling-export.js"; +import { installReactDevtoolsBackend } from "./devtools/install-backend.js"; +import type { ReactProfilerDataExport } from "./types/profiling-export.js"; +import type { DevtoolsGlobal } from "./types/react-devtools.js"; + +export interface ReactPerfHarness { + start: () => void; + stop: () => Promise; +} + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var __REACT_PERF__: ReactPerfHarness | undefined; +} + +export const createReactPerfHarness = (target: DevtoolsGlobal = globalThis): ReactPerfHarness => { + installReactDevtoolsBackend(target); + + const harness: ReactPerfHarness = { + start: () => { + const renderers = target.__REACT_DEVTOOLS_GLOBAL_HOOK__?.rendererInterfaces; + if (!renderers) return; + for (const renderer of renderers.values()) renderer.startProfiling(true); + }, + stop: () => Promise.resolve(collectProfilingExport(target)), + }; + + target.__REACT_PERF__ = harness; + return harness; +}; diff --git a/packages/browser/src/react-profiler/inject.ts b/packages/browser/src/react-profiler/inject.ts new file mode 100644 index 000000000..e60aa2e5c --- /dev/null +++ b/packages/browser/src/react-profiler/inject.ts @@ -0,0 +1,6 @@ +import { createReactPerfHarness } from "./harness.js"; + +// esbuilt into the IIFE the session injects via `addInitScript`, so it runs at +// document-start — the only moment installing the DevTools hook lets React +// attach to it. +createReactPerfHarness(); diff --git a/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts b/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts new file mode 100644 index 000000000..534c5a520 --- /dev/null +++ b/packages/browser/src/react-profiler/types/devtools-inline-backend.d.ts @@ -0,0 +1,5 @@ +// Types for `react-devtools-inline/backend`, which ships Flow source with no +// TypeScript types. Wired in via tsconfig `paths`. We only use `initialize`, +// which installs the DevTools hook; the renderer attaches itself to the hook +// when React loads, and we drive that renderer interface directly. +export const initialize: (windowOrGlobal: unknown) => void; diff --git a/packages/browser/src/react-profiler/types/profiling-export.ts b/packages/browser/src/react-profiler/types/profiling-export.ts new file mode 100644 index 000000000..090a22ccb --- /dev/null +++ b/packages/browser/src/react-profiler/types/profiling-export.ts @@ -0,0 +1,49 @@ +import type { PROFILING_EXPORT_VERSION } from "../devtools/collect-profiling-export.js"; + +export interface ReactProfilerChangeDescription { + context: Array | boolean | null; + didHooksChange: boolean; + isFirstMount: boolean; + props: Array | null; + state: Array | null; + hooks?: Array | null; +} + +export interface ReactProfilerSerializedElement { + displayName: string | null; + id: number; + key: number | string | null; + type: number; +} + +export interface ReactProfilerCommitDataExport { + changeDescriptions: Array<[number, ReactProfilerChangeDescription]> | null; + duration: number; + effectDuration: number | null; + fiberActualDurations: Array<[number, number]>; + fiberSelfDurations: Array<[number, number]>; + passiveEffectDuration: number | null; + priorityLevel: string | null; + timestamp: number; + updaters: Array | null; +} + +// One profiled root, exactly as the renderer's `getProfilingData` returns it. +export interface ReactProfilerRootDataBackend { + rootID: number; + displayName: string; + commitData: Array; + initialTreeBaseDurations: Array<[number, number]>; +} + +// The renderer's per-root data plus `elementNames`, the `fiberID → component +// name` map an agent needs to read the durations and change descriptions +// (which key everything by fiber id). +export interface ReactProfilerDataForRootExport extends ReactProfilerRootDataBackend { + elementNames: Array<[number, string]>; +} + +export interface ReactProfilerDataExport { + version: typeof PROFILING_EXPORT_VERSION; + dataForRoots: Array; +} diff --git a/packages/browser/src/react-profiler/types/react-devtools.ts b/packages/browser/src/react-profiler/types/react-devtools.ts new file mode 100644 index 000000000..c0f7958b3 --- /dev/null +++ b/packages/browser/src/react-profiler/types/react-devtools.ts @@ -0,0 +1,22 @@ +import type { ReactProfilerRootDataBackend } from "./profiling-export.js"; + +export type DevtoolsGlobal = typeof globalThis; + +// The subset of React DevTools' RendererInterface we drive directly, bypassing +// the frontend Store/bridge/wall. `getDisplayNameForElementID` is absent on +// older React. +export interface ReactRendererInterface { + startProfiling: (recordChangeDescriptions: boolean) => void; + stopProfiling: () => void; + getProfilingData: () => { dataForRoots: Array }; + getDisplayNameForElementID?: (id: number) => string | null; +} + +export interface ReactDevtoolsHook { + rendererInterfaces?: Map; +} + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevtoolsHook | undefined; +} diff --git a/packages/browser/src/session.ts b/packages/browser/src/session.ts new file mode 100644 index 000000000..03ad5319d --- /dev/null +++ b/packages/browser/src/session.ts @@ -0,0 +1,329 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import axe from "axe-core"; +import type { Browser, ConsoleMessage, Page, Request, Response } from "playwright-core"; +import { connectToBrowser, type BrowserConnection } from "./connect.js"; +import { + MAX_VIOLATION_TARGETS, + NAVIGATION_TIMEOUT_MS, + PERFORMANCE_OBSERVE_WINDOW_MS, + REACT_PROFILER_INJECT_FILE, + SETTLE_TIMEOUT_MS, +} from "./constants.js"; +import { collectPerformanceReport } from "./perf-observer.js"; +import type { + AccessibilityViolation, + BrowserConnectOptions, + ConsoleMessageEntry, + NetworkRequestEntry, + PageInspection, + PerformanceReport, + Viewport, +} from "./types.js"; + +// Which signals to collect during a single capture load. Listeners and the perf +// observers all attach before one navigation, so any combination costs one load. +interface CaptureSignals { + console: boolean; + network: boolean; + performance: boolean; +} + +interface CaptureResult { + console: ConsoleMessageEntry[]; + network: NetworkRequestEntry[]; + performance: PerformanceReport; +} + +const emptyPerformanceReport = (): PerformanceReport => ({ + longAnimationFrames: [], + largestContentfulPaintMs: null, + cumulativeLayoutShift: 0, +}); + +const resolveActivePage = async (browser: Browser): Promise => { + for (const context of browser.contexts()) { + const [firstPage] = context.pages(); + if (firstPage) return firstPage; + } + const context = browser.contexts()[0] ?? (await browser.newContext()); + return context.newPage(); +}; + +// A live handle to the attached page. The page state lives in the browser, so a +// session is cheap to create per command and there is no server to keep alive. +export class BrowserSession { + private constructor( + private readonly connection: BrowserConnection, + readonly page: Page, + ) {} + + static async attach(options: BrowserConnectOptions = {}): Promise { + const connection = await connectToBrowser(options); + const page = await resolveActivePage(connection.browser); + return new BrowserSession(connection, page); + } + + get launched(): boolean { + return this.connection.launched; + } + + async open(url: string): Promise { + await this.navigate(url); + } + + // Open `url` with the React DevTools profiler wired in. The init script has to + // run before the page's React loads (the only moment the hook can attach), so + // register it, drive this one load, then remove the registration. Leaving it + // registered would linger in the persistent Chrome we only attached to: + // stacking another copy on every `browser open` (each a separate script that + // re-installs the backend) and re-running on a later command's navigation. The + // page it just loaded keeps `window.__REACT_PERF__` for subsequent `eval`s. + async openWithReactProfiler(url: string): Promise { + const injectUrl = new URL(REACT_PROFILER_INJECT_FILE, import.meta.url); + const source = await readFile(injectUrl, "utf8").catch(() => null); + if (source === null) { + throw new Error( + `React profiler init script missing at ${fileURLToPath(injectUrl)}; rebuild @react-doctor/browser.`, + ); + } + const cdpSession = await this.page.context().newCDPSession(this.page); + await cdpSession.send("Page.enable"); + const { identifier } = await cdpSession.send("Page.addScriptToEvaluateOnNewDocument", { + source, + }); + try { + await this.navigate(url); + } finally { + await cdpSession + .send("Page.removeScriptToEvaluateOnNewDocument", { identifier }) + .catch(() => {}); + await cdpSession.detach().catch(() => {}); + } + } + + private async navigate(url?: string): Promise { + const options = { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "domcontentloaded" } as const; + await (url ? this.page.goto(url, options) : this.page.reload(options)); + await this.settle(); + } + + // A CDP device-metrics override, not page.setViewportSize, so it works on a + // page we only attached to and clears on disconnect — it never resizes the + // user's real window. + async setViewport(viewport: Viewport): Promise { + const cdpSession = await this.page.context().newCDPSession(this.page); + await cdpSession.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: false, + }); + } + + // The expression runs here in Node with the Playwright `page` in scope (the + // whole driver API), not in the page — so an agent acts on what `snapshot` + // showed it using Playwright's own selectors. + async evaluate(expression: string): Promise { + const run = new Function("page", `"use strict"; return (async () => (${expression}))();`) as ( + page: Page, + ) => Promise; + return run(this.page); + } + + // Wait for the page to stop changing before we read it: in-flight requests + // drain, then web fonts finish loading. Without this the design job + // screenshots a half-rendered frame (lazy images, fade-in, fallback fonts). + // Bounded and best-effort — a page that never goes idle hits the cap. + private async settle(): Promise { + await this.page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); + await this.waitForFonts(); + } + + // `document.fonts.ready` can stall on a page that keeps registering fonts, so + // cap it — otherwise settle() (on the hot path of every command) could hang. + private waitForFonts(): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, SETTLE_TIMEOUT_MS); + void this.page + .evaluate(() => document.fonts?.ready.then(() => undefined)) + .catch(() => undefined) + .finally(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + async snapshot(): Promise { + return this.page.locator("body").ariaSnapshot(); + } + + // Settle first so a screenshot taken straight after an SPA navigation (or in a + // separate command that reattaches) still captures the finished page. + async screenshot(path?: string): Promise { + await this.settle(); + return this.page.screenshot({ path }); + } + + async audit(url?: string): Promise { + if (url) { + await this.navigate(url); + } else { + await this.settle(); + } + return this.runAxe(); + } + + // axe is injected with `evaluate`, not a