Crispy Profiling

io.github.edgeorgiev0.1.0更新于 Oct 5, 2026

Deterministic React render profiling: renders, wasted renders and their causes per component.

概览

AI 生成的概览

在无头 Chromium 中分析 React 重渲染,报告哪些组件渲染、原因以及如何修复可避免的渲染。

功能
在无头 Chromium 中打开正在运行的 React 应用,回放脚本化的交互,并按组件记录渲染次数及其原因(props、state、context、父组件、被重建的回调或值)。提供 profile_url、run_scenarios、test_render_snapshots、compare_reports、inspect_component 等 MCP 工具,并可将结果与已提交的渲染快照或预算比较。报告包含根因提示和修复建议,例如 React.memo、useCallback 或 useMemo。
适用场景
适合让助手衡量某次 React 改动是否真的减少了重渲染,或在 CI 中按已提交的快照检查渲染次数。也适合一次性排查某个组件为何重渲染。
运行要求
通过 npm 包 crispy-profiling 以 stdio 在本地运行(npx crispy-profiling mcp)。需要 Node.js、安装步骤下载的 Chromium、可在某个 base URL 访问的 React 开发构建,以及描述场景的 crispy.config.json。可选的登录步骤可从 E2E_PASSWORD 等环境变量读取密钥。
安装前请注意
它会用真实浏览器操作你的应用,可执行点击、输入、拖拽和导航,并会启动或复用你的开发服务器。保存的会话文件(storageState)包含 cookie 和 localStorage,应避免提交到 git。登录凭据可能通过 E2E_PASSWORD 等环境变量传入。报告和快照可能包含源码文件路径和行号。

安装

在 SourceWeft 中

  1. 打开 控制台中的 Crispy Profiling,将其添加到工作区。
  2. 为需要使用其工具的对话启用该服务。

Desktop only,通过 STDIO。 STDIO 服务会启动本地进程,因此需要 SourceWeft 桌面宿主。

其他 MCP 客户端

参照 仓库 中的启动说明。

README

🥓 crispy-profiling

Snapshot testing for React re-renders — deterministic, runtime-proven, with the fix.

[CI] [npm] [License: MIT] [OpenSSF Scorecard]

[crispy test catches a PR that re-renders 20 rows, explains why and verifies the fix]

Status: early (0.x), improving every week. Validated on five open-source apps (Redux Essentials, Next.js App Router Playground, Excalidraw, shadcn-admin, react-admin): it found a fixable re-render problem in each. See Known limitations and the changelog. Bug reports, wrong hints and case studies are the most valuable contribution right now.

crispy-profiling opens your React app in headless Chromium, runs the interactions you describe, and tells you which components rendered, how many times, why (props / state / context / parent) and which renders were avoidable. Render counts are deterministic, so two reports of the same scenario only differ when the code changed. That makes it a reliable feedback loop for:

  • AI coding agents: an MCP server and an Agent Skill so Claude Code, Cursor, Codex, Copilot & co. can measure a re-render fix instead of guessing.
  • CI: render budgets and baseline comparison that fail a PR when a component starts re-rendering.
  • You: a CLI that answers "why does this re-render?" without opening DevTools.

No code changes in your app: it uses the same hook React DevTools uses. Tested on React 19 and validated on 18.3 and 19.0 apps; React 16.8–17 expose the same hook but are not tested.

Quick start

bash
npm i -D crispy-profilingnpx crispy install                                   # downloads the matching Chromium (once)npx crispy init      # detects Next.js/Vite, the dev URL and your dev command → crispy.config.jsonnpx crispy test      # starts your dev server, records crispy.snap.json → commit it

From then on, npx crispy test (locally, in CI or from an AI agent) fails when a component starts re-rendering, and tells you why and how to fix it:

text
| 🔴 regressed | list / interaction | Row | renders | — → 20 | recreated on every render (rendered at  src/App.tsx:222 (App)): `onSelect` is a new function with the same code in `App`: wrap it in  useCallback with the values it uses as dependencies. Then wrap this component in React.memo. |

For a one-off look at a flow, npx crispy run prints every component with its causes and a fix:

text
| Component | Renders | Avoidable | Callback | Causes (props/state/context/unstable/callback/parent) | Rendered at          | Why / how to fix || Row       |      20 |         0 |       20 | 0/0/0/0/20/0 | `src/App.tsx:222 (App)` | `onSelect` is a new function with the same code in `App`: wrap it in useCallback… || Status    |       1 |         1 |        0 | 0/0/0/1/0/0  | `src/App.tsx:178 (App)` | `style` is recreated with equal data in `App`: hoist it out of the component or wrap it in useMemo… || App       |       1 |         0 |        0 | 0/1/0/0/0/0  | `src/main.tsx:12`       | state updates here cause 23 avoidable render(s) below (`Row`, `Header`, `Status`)… |

Every phase starts with Root causes — fix these first: the few components that recreate a value, recreate a context value or update state that re-renders unchanged children, ranked by the avoidable renders they cause, with the child where one React.memo would stop most of a cascade.

"Rendered at" and definedIn are mapped back to your original source files and lines through the source maps your dev server or bundler serves (Vite, webpack, Turbopack); without source maps they refer to the code the browser runs.

Works with Vite and Next.js (Turbopack and webpack dev servers); framework internals such as the Next.js dev overlay are filtered out. Profile the development build.

Render snapshots (crispy test)

Like Jest snapshots, but for re-renders. Commit the expected render counts of your key flows; every PR — written by a person or an AI agent — is checked against them at runtime.

bash
npx crispy test        # 1st run: writes crispy.snap.json → commit itnpx crispy test        # later: fails if any component renders more (or more avoidably)npx crispy test -u     # accept intended changes / lock in improvementsnpx crispy test --ci   # in CI: a missing snapshot fails instead of being written (auto-detected; --no-ci to opt out)

When something regresses you get the component, the cause, where it is rendered and the fix (see Quick start).

crispy.snap.json has one line per component, so the PR diff shows exactly which counts changed:

json
"interaction": {  "commits": 1,  "components": {    "App": { "renders": 1, "avoidable": 0 },    "Header": { "renders": 1, "avoidable": 1 }  }}

Rules: any increase in commits, renders or avoidable renders fails (snapshot.tolerance allows slack), and every metric is checked independently, so an improvement never hides a regression. Counts that varied between runs are stored as [min, max] ranges and only fail outside them (-u keeps the known range instead of narrowing it). Decreases pass and suggest -u. New UI passes and is reported (record it with -u); if it already renders avoidably it is flagged ⚠️ (set snapshot.failOnNewAvoidable to fail instead). A known component that starts re-rendering in a phase still fails. A rename, even combined with a move to another file, with the same counts is reported as 🔁 renamed, not as a regression. crispy test never edits the committed file on its own; the snapshot always covers every component (even with topComponents); budgets still apply.

Configuration

crispy.config.json (JSON Schema):

json
{  "$schema": "./node_modules/crispy-profiling/schema/crispy.config.schema.json",  "baseUrl": "http://localhost:5173",  "runs": 3,  "scenarios": [    {      "name": "search",      "path": "/products",      "steps": [        { "action": "type", "selector": "#search", "value": "shoes" },        { "action": "phase", "name": "sort" },        { "action": "click", "selector": "text=Price: low to high" }      ],      "budgets": {        "interaction": { "maxAvoidableRenders": 0, "components": { "ProductCard": { "maxRenders": 20 } } }      }    }  ]}
FieldDefaultDescription
baseUrl—Origin of the running app.
runs3Runs per scenario; the report keeps median/min/max and flags unstable counts.
settleMs300A step is "settled" after this long without React commits and without in-flight network requests.
maxSettleMs10000Max wait per step. Pages that never settle (polling, animations) produce a warning in the report instead of hanging.
cpuThrottle1Slow the CPU down (e.g. 4) to check counts on a slow CI runner or low-end device. Counts should not change.
clockfalseControl timers with a fake clock (setTimeout, setInterval, requestAnimationFrame, Date, performance) so polling/animated apps give deterministic counts.
timeoutMs30000Max time for navigation, a step or settling.
timingsfalseAdd component self time + LCP/CLS/long tasks. Off by default: timings are not reproducible.
topComponents0Keep only the N most-rendered components per phase (0 = all).
viewport1280×800Browser viewport.
browserheadlessexecutablePath, channel (e.g. "chrome"), headless. CRISPY_CHROMIUM_PATH also works.
includeInternalsfalseShow framework/library internals (components defined in node_modules that only library code renders, e.g. Next.js router internals). Library components your code renders directly are always shown.
webServer—{ "command": "npm run dev" }: crispy starts your dev server, waits for baseUrl (or url) and stops it afterwards; a server already running there is reused. crispy init fills it in.
login—{ "path": "/login", "steps": [...] }: sign in once before profiling (never counted). Use "${E2E_PASSWORD}" to read secrets from the environment.
storageState—A saved session file (cookies + localStorage), e.g. from crispy login for SSO/OAuth logins. Keep it out of git.
randomseededMath.random returns the same sequence in every run, so fake data, IDs and animations render the same way. native keeps the browser's.
snapshotcrispy.snap.json, 0, falsefile (relative to the config file), tolerance and failOnNewAvoidable used by crispy test.
compare10%, 1rendersIncreasePct and minRendersDelta used by compare.

Steps: click, hover, fill, type, press, select (a <select> option), drag (selector to to, or by dx/dy, with pointer steps), scroll, waitFor (state: visible, hidden, attached, detached), wait, goto, phase. type presses one key at a time and waits for React to finish (including deferred values and transitions) before the next key, so concurrent features give the same counts on fast and slow CPUs. Renders before the first step are recorded in phase load; renders during steps go to interaction unless you name phases yourself with { "action": "phase", "name": "..." }.

Budgets (per phase): maxCommits, maxTotalRenders, maxAvoidableRenders, maxWastedRenders, and per component maxRenders / maxAvoidableRenders / maxWastedRenders. A budget for a phase the scenario never produces is a config error; a component budget that never matches a rendered component produces a warning (likely a typo). Budgets always see every component, even when topComponents trims the report.

What the numbers mean

FieldMeaning
rendersTimes the component function/class rendered (mounts + updates).
avoidableRendersUpdates where nothing really changed: wasted renders plus renders caused only by recreated-but-equal data (objects, arrays, elements, dates, maps…). Certainly avoidable.
callbackRendersUpdates caused only by functions recreated with the same code (inline callbacks). Avoidable if the values they capture did not change — crispy cannot see captures, so they are reported apart.
wastedRendersUpdates where props (shallow), state and consumed context were all unchanged.
causes.props / state / contextUpdates where that input changed (one update can have several causes).
causes.unstableOnly data identities changed: object/array literals, dates, maps, context values or hook results recreated with equal contents.
causes.callbackOnly functions were recreated with the same code (bound/native functions count as real changes).
causes.parentUpdates with no changed input: the parent re-rendered (same as wasted).
unstablePropsProp keys recreated with equal data — fix with useMemo or by hoisting constants.
callbackPropsProp keys that were recreated callbacks — fix with useCallback and the right dependencies, or React Compiler.
changedPropsProp keys whose identity changed, with counts — the "why" behind causes.props.
triggeredByComponents whose own state update started the cascade that re-rendered this one, with counts. Fix the trigger, not every child.
recreatedContextFromComponents that own a context provider whose value was recreated with equal content (e.g. value={{ user, logout }}) — memoize the value there.
memotrue when the component is wrapped in React.memo, so hints never suggest wrapping it again.
creators`prop
staleMemo`prop
providerAtWhere the provider of a recreated context value is rendered.
compiledtrue when React Compiler compiled the component.
locationsUp to 3 places where the component is rendered, as file:line (Owner) (owner JSX call site, most frequent first), resolved through source maps when available.
Item (src/List.tsx) keysComponents are identified by name and the file that defines them (resolved through the DevTools protocol). Distinct components that share a name are keyed as Name (file), so adding an unrelated Item never renames existing ones; snapshots store the file and keep matching. When files cannot tell them apart (styled-components, HOC factories, several components in one file), they are keyed by where they render (Item @ src/Card.tsx:12); numbered keys (Item#2) are the last resort. Keys stay the same across goto navigations.
definedInFile where the component function is defined.
stablefalse when counts differ between runs (timers, network, randomness).

Every Markdown report (crispy run, crispy test, MCP tools) adds a Why / how to fix column built from these fields. Hints point at the root cause: the component whose state starts a cascade, the owner that recreates a prop (with its file:line), or the provider that recreates a context value.

Profile the development build: production builds minify component names.

CLI

text
crispy init [--base-url <url>]          Create crispy.config.json (detects framework, URL, dev command)crispy install [--with-deps]            Download the Chromium build crispy usescrispy login [-c file] [--path /login]  Sign in by hand in a browser window and save the sessioncrispy run [-c file] [-o file] [-s scenario...] [--markdown file] [--no-fail]crispy test [-c file] [-u|--update] [--ci] [-s scenario...] [--markdown file]crispy compare <base.json> <head.json> [--threshold 10] [--min-delta 1] [--markdown file] [--json file] [--no-fail]crispy mcp                              Start the MCP server on stdio

Exit codes: 0 ok · 1 budget violation or regression · 2 usage/runtime error.

For AI agents

MCP server

Tools: profile_url, run_scenarios, test_render_snapshots, compare_reports, inspect_component.

json
{  "mcpServers": {    "crispy-profiling": { "command": "npx", "args": ["-y", "crispy-profiling@latest", "mcp"] }  }}

Claude Code: claude mcp add crispy-profiling -- npx -y crispy-profiling@latest mcp

Claude Code plugin (MCP server + skill)

text
/plugin marketplace add edgeorgie/crispy-profiling/plugin install crispy-profiling@crispy-profiling

Agent Skill (Claude Code, Cursor, Codex, Copilot, Gemini CLI, …)

bash
npx skills add edgeorgie/crispy-profiling

The skill teaches the agent the measure → fix → re-measure → compare loop and how to map each signal to a fix (React.memo, useCallback, useMemo, context splitting, state colocation).

CI (GitHub Action)

yaml
permissions:  contents: read  pull-requests: write   # lets crispy comment on the PRsteps:  - uses: actions/checkout@v7  - uses: actions/setup-node@v7    with: { node-version: 22 }  - run: npm ci  - uses: edgeorgie/crispy-profiling@v0   # starts your dev server (webServer) and runs `crispy test --ci`

The step fails when any component renders more than the committed snapshot allows (or a budget is exceeded). The job summary — and one PR comment, updated on every push — lists each regression with its cause, where it is rendered and the suggested fix (comment: false to disable). command: run (with an optional baseline report) is available for budget-only or baseline-comparison setups. Full workflow: examples/github-workflow.yml.

Programmatic API

ts
import { compareReports, parseConfig, profile } from 'crispy-profiling';
const config = parseConfig({ baseUrl: 'http://localhost:5173', scenarios: [{ name: 'home' }] });const report = await profile(config);

How it compares

Use the tools together: they answer different questions.

ToolWhat it is forWhere crispy fits
React DevTools ProfilerInteractive, manual profiling in your browsercrispy runs the same kind of analysis headless, on scripted interactions, every time
React ScanVisual highlighting of re-renders while you use the app; also has a programmatic onRender APIcrispy turns render counts into committed snapshots that fail CI, with a fix hint per component
why-did-you-renderConsole notifications in development about avoidable re-renders (Babel setup)crispy needs no app changes and reports per interaction, deterministically
React DoctorStatic analysis (lint rules) of the codebase with a scorecrispy observes what actually rendered at runtime; static findings and runtime proof complement each other
React CompilerAutomatic memoization at build timecrispy shows what the compiler did not cover (e.g. dependencies that change every render) and verifies the result

What crispy adds: deterministic counts (same code → same report), snapshots in CI, root-cause hints (who creates the unstable value, which dependency changes) and verification (the fix shows up as 🟢 improved).

Known limitations

  • Development builds only. Production builds strip component names and the debug information crispy uses for causes and locations.
  • Render counts are not milliseconds. crispy finds avoidable renders deterministically; whether they matter depends on how expensive the components are. Use timings: true (not reproducible) to see self time, LCP and long tasks.
  • Web only, Chromium only. No React Native; other browsers are not needed for render counts.
  • Scenarios are written by hand (selectors and steps). crispy init creates a starting point.
  • Hints are heuristics. In our validation on real apps most hints pointed at the right component, but not all were directly actionable; report a wrong hint with the report attached and we will fix it.
  • Apps with real network timing can vary between runs: counts are stored as ranges, randomness is seeded and one extra commit is tolerated, but very timing-dependent flows may need waitFor steps or clock: true.

How this is built

crispy-profiling is developed with AI coding agents (Claude Code) under human direction, with the same rules as any contribution: atomic commits, tests, CI on Node 20/22/24 and review. Every milestone is checked by an independent agent acting as a hostile reviewer and validated on real open-source apps; every number in this README and in the changelog can be reproduced with the commands shown. Found something wrong? Please open an issue.

How it works

An init script installs (or wraps) __REACT_DEVTOOLS_GLOBAL_HOOK__ before React loads. On every commit it walks the new fiber tree against its alternate, like React DevTools, and records each component that performed work, comparing props, hook state and context values to classify the cause. See docs/ARCHITECTURE.md.

Contributing

Issues and PRs are welcome — see CONTRIBUTING.md (GitFlow: branch from develop). AI coding agents: start with AGENTS.md; docs index for LLMs: llms.txt.

License

MIT © Edwin Jorge

来源:README.md,提交 dc27f6a

工具

0
工具元数据尚未被收录。

版本历史

1
  1. v0.1.0最新Oct 5, 2026