You typed one letter. React ran 1,227 components. 1,206 of them had nothing new to show. You felt it as lag. Nobody saw it.
crispy saw it. It opens your app in headless Chromium, counts every render, and names the line: src/App.tsx:222.
Wrap the callback. Memoize the row. Same keystroke, 21 renders. The report is byte-for-byte reproducible, so the number is a fact, not a feeling.
npx crispy test snapshots the counts. The pull request that brings the 1,206 back fails, with the cause and the fix in the comment.
npx crispy scan detects Vite or Next.js, starts your dev server, visits a few routes and tries the safe interactions: buttons, tabs, inputs, links. Never anything named delete, pay, sign out or submit, in several languages. Read-only: writes never leave the browser.
Every render gets a reason: props, state, context or parent. The avoidable ones get a root cause: a callback recreated on every render, an inline object with equal data, a context value rebuilt each time. Plain English first, the technical detail after.
Each finding points at a file and a line and says what to do: useCallback, useMemo, hoist, React.memo. Your coding agent can apply it through the MCP server and measure that it worked, instead of guessing.
npx crispy test records crispy.snap.json. Commit it. In CI the GitHub Action fails the pull request that adds renders and posts one comment with the cause and the fix. The snapshot is the gate; milliseconds are only information.
A small list app, instrumented the way crispy sees it. Every keystroke updates App state; the report on the right is what crispy would print.
| Component | Renders | Avoidable | Why it rendered · how to fix |
|---|
Pick the one you will use first. Each opens with the commands to copy. Start with the terminal: three lines to the first finding.
Zero config to the first finding. Then a snapshot you commit, and a test that reads it.
scan finds and profiles interactions, writes crispy.config.json.run prints every component with its causes and the fix.test records or checks crispy.snap.json; --ci fails on regressions.compare diffs two reports. Exit codes: 0 ok · 1 regression · 2 usage.npm i -D crispy-profiling npx crispy install # Chromium once; or CRISPY_CHROMIUM_PATH npx crispy scan # find · explain · write the config npx crispy test # record crispy.snap.json, commit it npx crispy test --ci # fail when a component adds renders
Fails the pull request that adds renders. Installs Chromium itself. One PR comment, updated on every push, with the cause, the place and the fix.
@v0 or a release tag.scenarios input splits a slow suite across a job matrix.name: crispy on: pull_request permissions: { contents: read, pull-requests: write } jobs: renders: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci - uses: edgeorgie/crispy-profiling@v0
For the agent that writes the fix. Claude Code, Cursor, Codex, Copilot & co. profile before and after, and get the same numbers for the same code.
scan_app, profile_url, test_render_snapshots, compare_reports, inspect_component.io.github.edgeorgie/crispy-profiling.// .mcp.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
The know-how, packaged. The react-render-profiling Agent Skill teaches when to profile, how to read a report and how to apply each hint. The Claude Code plugin bundles it with the MCP server.
# inside Claude Code /plugin marketplace add edgeorgie/crispy-profiling /plugin install crispy-profiling@crispy-profiling # then just ask "Why does the list re-render when I type? Profile it, fix it, prove it."
No dashboards. A short receipt: the root causes first, each with the file, the line and the fix. Everything else is in the JSON for when you want it. Reports are deterministic: sorted, no timestamps, so two runs of the same code are identical.