react-perf-recorder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/claude/README.md +10 -0
- package/claude/agents/perf-recorder.md +44 -0
- package/claude/mcp.json +8 -0
- package/claude/skills/react-perf-recorder/SKILL.md +52 -0
- package/claude/skills/react-perf-recorder/references/causes-and-actions.md +47 -0
- package/claude/skills/react-perf-recorder/references/from-scripts.md +36 -0
- package/claude/skills/react-perf-recorder/references/getting-a-recording.md +41 -0
- package/claude/skills/react-perf-recorder/references/measuring-a-fix.md +42 -0
- package/claude/skills/react-perf-recorder/references/panel.md +21 -0
- package/claude/skills/react-perf-recorder/references/reading-a-recording.md +55 -0
- package/dist/browser/chunk-7DJUCCWG.js +447 -0
- package/dist/browser/chunk-NTY2W4HE.js +182 -0
- package/dist/browser/client.d.ts +589 -0
- package/dist/browser/client.js +7161 -0
- package/dist/browser/index-BlkKhwHe.d.ts +585 -0
- package/dist/browser/plugins/proxy-memoize.d.ts +7 -0
- package/dist/browser/plugins/proxy-memoize.js +41 -0
- package/dist/browser/plugins/react-query.d.ts +5 -0
- package/dist/browser/plugins/react-query.js +74 -0
- package/dist/browser/plugins/zustand.d.ts +11 -0
- package/dist/browser/plugins/zustand.js +171 -0
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +12 -0
- package/dist/cli.js +23119 -0
- package/dist/engine.iife.js +3661 -0
- package/dist/node/chunk-HS2BJBJX.js +170 -0
- package/dist/node/plugin-api-zXFxjYba.d.cts +61 -0
- package/dist/node/plugin-api-zXFxjYba.d.ts +61 -0
- package/dist/node/plugins/proxy-memoize.cjs +214 -0
- package/dist/node/plugins/proxy-memoize.d.cts +16 -0
- package/dist/node/plugins/proxy-memoize.d.ts +16 -0
- package/dist/node/plugins/proxy-memoize.js +51 -0
- package/dist/node/plugins/react-query.cjs +32 -0
- package/dist/node/plugins/react-query.d.cts +6 -0
- package/dist/node/plugins/react-query.d.ts +6 -0
- package/dist/node/plugins/react-query.js +7 -0
- package/dist/node/plugins/zustand.cjs +247 -0
- package/dist/node/plugins/zustand.d.cts +17 -0
- package/dist/node/plugins/zustand.d.ts +17 -0
- package/dist/node/plugins/zustand.js +61 -0
- package/dist/node/vite.cjs +1262 -0
- package/dist/node/vite.d.cts +103 -0
- package/dist/node/vite.d.ts +103 -0
- package/dist/node/vite.js +1072 -0
- package/docs/contributing.md +24 -0
- package/docs/how-it-works.md +34 -0
- package/docs/mcp.md +62 -0
- package/docs/measuring-a-fix.md +65 -0
- package/docs/options.md +22 -0
- package/docs/panel.md +59 -0
- package/docs/plugins.md +57 -0
- package/docs/recording.md +44 -0
- package/package.json +139 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Working on it
|
|
2
|
+
|
|
3
|
+
`npm test` runs the unit tests, `npm run test:e2e` the browser ones against the fixture app in
|
|
4
|
+
`test/e2e/fixture-app`, on React 18 and 19. The fixture doubles as the demo:
|
|
5
|
+
|
|
6
|
+
- `/app` — a small team chat with a store, a live feed, a polled query and a composer, with no bugs;
|
|
7
|
+
- `/bug/<id>` — the same app with one seeded re-render bug each (`/bug/whole-object`), for the tests;
|
|
8
|
+
- `/basics/<id>` — textbook mistakes on bare pages (`/basics/memo`, `/basics/state` …), the broken and the fixed
|
|
9
|
+
version side by side with renders and mounts counted on every row;
|
|
10
|
+
- `/advanced/<id>` — harder ones: a chain of effects, a measurement, a heavy list, a query's fields.
|
|
11
|
+
|
|
12
|
+
`/` lists the basics and the advanced cases. The e2e suite needs a dev server because the Vite plugin is half of
|
|
13
|
+
what is under test.
|
|
14
|
+
|
|
15
|
+
`npm run fixture` opens it by hand on http://localhost:5391; `npm run dev:pages` serves it as the project's site,
|
|
16
|
+
under `/react-perf-recorder/` on http://localhost:5393, and `npm run build:pages` builds that site into `dist-pages`. Playwright starts its own copies of the server on 5391
|
|
17
|
+
(React 18) and 5392 (React 19): stop yours first, or move the tests with `FIXTURE_PORT` / `FIXTURE_PORT_19`.
|
|
18
|
+
`E2E_PROJECT=react18` runs one version only.
|
|
19
|
+
|
|
20
|
+
`react-perf-recorder mcp --reload` restarts the MCP server whenever the CLI is rebuilt, for working on the server
|
|
21
|
+
from a checkout.
|
|
22
|
+
|
|
23
|
+
Commits follow [CLAUDE.md](../CLAUDE.md). Pushes to `dev` run type checks and unit tests; `main` runs the e2e suite
|
|
24
|
+
too.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
|
|
3
|
+
- Commits are caught by a setter on `FiberRoot.current`: React assigns it once per commit. The lanes of that commit
|
|
4
|
+
are the bits it took back off `pendingLanes`. The DevTools hook is left to its owners (react-grab, React DevTools).
|
|
5
|
+
- A fiber rendered when its props, hook list or context dependencies changed since the last commit it was seen in;
|
|
6
|
+
untouched subtrees (`child === alternate.child`) are skipped.
|
|
7
|
+
- A render's way down is a chain of links interned in one trie (parent → name → reason), so a render costs three map
|
|
8
|
+
lookups and no strings, whatever the chain's length.
|
|
9
|
+
- Hook names come from re-running the component with a stand-in dispatcher, as React DevTools does — only on Stop,
|
|
10
|
+
only for components in the report, never inside a commit. It knows React 19's hooks too (`use`,
|
|
11
|
+
`useActionState`, `useOptimistic`, `useMemoCache`).
|
|
12
|
+
- A component's file comes from the fiber on React 18 and from its owner stack on React 19, where the position is
|
|
13
|
+
the built module's: the dev server maps it back, and reads a memo's dependency array from the source.
|
|
14
|
+
- Timers are wrapped once at page boot, so intervals started on mount are seen; a callback becomes a cause only when
|
|
15
|
+
React marked new work during it, and the cause goes to the components that work belongs to.
|
|
16
|
+
- The app's `react-dom/client` is proxied so the recorder learns of a root the moment `createRoot` returns — that is
|
|
17
|
+
what makes recording from the page load possible.
|
|
18
|
+
- Store and memoizer plugins replace `zustand` and `proxy-memoize` for the app's imports only, so libraries keep the
|
|
19
|
+
originals and memoization behaves the same.
|
|
20
|
+
- The panel is a preact view in a shadow root on `<html>`, never the app's React; the outlines are drawn on one
|
|
21
|
+
canvas with `pointer-events: none`.
|
|
22
|
+
|
|
23
|
+
## Limits
|
|
24
|
+
|
|
25
|
+
- React 18.2+ and 19.1+, dev builds. React 19.0 dropped `_debugSource` before owner stacks landed in 19.1, so there
|
|
26
|
+
is no file for a component there; a recording made on it says so.
|
|
27
|
+
- Store writes that did not lead to a commit in the area are not recorded — there is no store journal by design.
|
|
28
|
+
- Two timers that update the same component between commits are attributed to the first of them.
|
|
29
|
+
- Only the top level of a module is named: a `memo` inside a function keeps React's `Memo`.
|
|
30
|
+
- StrictMode doubles render-time selector calls.
|
|
31
|
+
- Hook names re-run the component: side effects in render run once more.
|
|
32
|
+
- State names are known for the roots whose hooks are named at Stop (the top 30 inside the area, 10 outside), and
|
|
33
|
+
dependency names for the memos of the 15 components inspected; the rest keep their number. A dependency is named
|
|
34
|
+
only when the array is a literal in the app's code, and inside a custom hook only when it has one memo.
|
package/docs/mcp.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# MCP server, CLI and scripts
|
|
2
|
+
|
|
3
|
+
## MCP server
|
|
4
|
+
|
|
5
|
+
`npx react-perf-recorder init-claude` registers it in `.mcp.json` (with a skill and an agent in `.claude/`; `--force`
|
|
6
|
+
overwrites them). By hand:
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{ "mcpServers": { "react-perf-recorder": { "command": "node", "args": ["node_modules/react-perf-recorder/dist/cli.js", "mcp"] } } }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Tool | |
|
|
13
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| `list_recordings` | Newest first, with status, area, commits, renders, the top root |
|
|
15
|
+
| `get_recording` | `id` (`latest`, `latest-1`), `section`: `summary` (default), `actions`, `roots`, `outside`, `causes`, `components` (with ways), `timeline` (with each commit's cascade), `memos`, `frames`, `plugins`, `plugin:<name>`…; `hooks: 'short'` |
|
|
16
|
+
| `record_page` | Opens a page in a browser of its own, records it and returns the session id. `ms`, `scope`, `watch`, `script`, `replay` (a recording id: do its actions again), `sample`, `fromLoad`, `viewport`, `throttle`, `state`, `cdp`, `via` |
|
|
17
|
+
| `wait_for_recording` | Blocks until the person finishes a recording (`until: 'done'`) or starts one |
|
|
18
|
+
| `compare_recordings` | Before/after: totals, roots, causes, the same actions, plugin metrics; warns when the runs differ |
|
|
19
|
+
|
|
20
|
+
The sessions folder comes from `--dir`, then `REACT_PERF_RECORDER_DIR`, then `./.agent-artifacts/perf-recorder`.
|
|
21
|
+
|
|
22
|
+
`record_page` needs the dev server running and `playwright` installed in the project — it is never a dependency of
|
|
23
|
+
this package. `scope: 'MessageList'` records one component by the name it is exported under; an area that is not
|
|
24
|
+
mounted answers with the names that are.
|
|
25
|
+
|
|
26
|
+
A page behind a sign-in:
|
|
27
|
+
|
|
28
|
+
- `via` opens a link that signs the browser in first — a debug URL with a token, a magic link — and records the page
|
|
29
|
+
after it;
|
|
30
|
+
- `react-perf-recorder login <url>` keeps a session for later runs: a real browser to sign in by hand, or
|
|
31
|
+
`--for <selector>` / `--wait <ms>` when the link signs in by itself;
|
|
32
|
+
- `cdp: 'http://localhost:9222'` records in a browser you already have open and signed in; it is never closed.
|
|
33
|
+
|
|
34
|
+
A page that redirects to a login says so instead of recording the login form. A token never reaches a recording.
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
react-perf-recorder mcp MCP server over stdio [--reload: restart when the CLI is rebuilt]
|
|
40
|
+
react-perf-recorder list sessions, newest first [--limit 20]
|
|
41
|
+
react-perf-recorder show [id] one session [--section summary|roots|components|…] [--top 10]
|
|
42
|
+
react-perf-recorder pull wait for the next finished recording and print its summary
|
|
43
|
+
react-perf-recorder record <url> record a page [--ms] [--scope] [--watch] [--script] [--from-load] [--via] …
|
|
44
|
+
react-perf-recorder login <url> keep a signed-in session for later recordings
|
|
45
|
+
react-perf-recorder init-claude copy the skill and the agent, register the MCP server [--force]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## From scripts
|
|
49
|
+
|
|
50
|
+
The page exposes the engine as `window.__REACT_PERF_RECORDER__.engine`:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const rec = await engine.record(10_000, { source: 'script:my-check', scope: { selector: '[data-testid="orders"]' }, watch: ['OrderRow'] });
|
|
54
|
+
rec.id; // saved session id
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`engine.start()` / `engine.stop()`; `scope: { selector, component?, level? } | { names: [...] }`, `zones`,
|
|
58
|
+
`highlight: false` for timing runs, `sampleReasons: true` for fast recordings. `window.__REACT_PERF_RECORDER__.format`
|
|
59
|
+
prints reasons and hook chains the way the panel and the MCP server do. A page with nothing to record — a landing
|
|
60
|
+
page, docs — can call `window.__REACT_PERF_RECORDER__.panel?.suppress(true)` on mount and `suppress(false)` on
|
|
61
|
+
unmount: the panel, its outlines and shortcuts are gone for that page only, and nothing is remembered. Pages without the Vite plugin can load
|
|
62
|
+
`react-perf-recorder/engine.iife.js` (core only: no plugins, no saving).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Measuring a fix
|
|
2
|
+
|
|
3
|
+
A before/after says whether a change helped, and by how much. It is two recordings of the same scenario, one on the
|
|
4
|
+
code as it was and one on the change, set side by side.
|
|
5
|
+
|
|
6
|
+
## The route
|
|
7
|
+
|
|
8
|
+
1. **Record the problem.** From the panel (**● Rec**, or **↺ Page load** for a page load) or with `record_page`.
|
|
9
|
+
Keep its id.
|
|
10
|
+
2. **Make the change** — in a separate worktree when the project is a git repository (below).
|
|
11
|
+
3. **Do the same scenario again.**
|
|
12
|
+
- The actions happen in the page (tabs, filters, typing, modals, store updates): **↻ Repeat** in the panel, or
|
|
13
|
+
`record_page` with `replay: <id>`. It reloads the page and does the same clicks and typing at the same pace.
|
|
14
|
+
- The actions wait on requests (server search, paging, saving): a replay does not wait for data. Write a `script`
|
|
15
|
+
that waits for what shows the data is there, and run it with `record_page` on both sides.
|
|
16
|
+
4. **Compare.** The panel sets a second recording of the same page and area against the one before it;
|
|
17
|
+
`compare_recordings` does the same for any two ids.
|
|
18
|
+
5. **Put back what was not kept.** A fix that is not taken leaves no trace: remove the worktree, or revert the file.
|
|
19
|
+
|
|
20
|
+
## A separate worktree
|
|
21
|
+
|
|
22
|
+
The working tree may hold changes that are not yours, and a change made in it for a measurement is easy to leave
|
|
23
|
+
behind. With git, make the fix in a worktree instead and run a second dev server from it:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git worktree add --detach ../app-fix
|
|
27
|
+
cd ../app-fix && npm ci
|
|
28
|
+
REACT_PERF_RECORDER_DIR=/abs/path/to/app/.agent-artifacts/perf-recorder npm run dev -- --port 5174
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **One sessions folder for both.** `REACT_PERF_RECORDER_DIR` points the second server at the first one's folder,
|
|
32
|
+
so `compare_recordings` finds both recordings. A project with `outDir` in its config overrides the variable; set
|
|
33
|
+
the same absolute path there.
|
|
34
|
+
- **The same page on the other port.** A replay takes the recording's URL; pass `url` with the second port.
|
|
35
|
+
- **The worktree starts from the last commit**, without the working tree's uncommitted changes. When they touch the
|
|
36
|
+
page, record "before" in the worktree too, before the change, so the two sides differ by the fix alone.
|
|
37
|
+
- Both versions run at once: before and after can be recorded in turn, and again, without switching code.
|
|
38
|
+
- `--detach` makes no branch; commit the fix from the worktree only once it is kept.
|
|
39
|
+
- `git worktree remove ../app-fix` when done.
|
|
40
|
+
|
|
41
|
+
## A fair comparison
|
|
42
|
+
|
|
43
|
+
- **The same conditions**: viewport, CPU throttling, data, account, area. `compare_recordings` warns when the
|
|
44
|
+
viewport, page, area, conditions or duration differ.
|
|
45
|
+
- **Outlines off** on both sides. Drawing them costs frame time; a comparison with them on one side says so.
|
|
46
|
+
- **Renders over milliseconds.** Render counts repeat from run to run; timings move with the machine, other tabs and
|
|
47
|
+
the garbage collector. Compare timings only between runs made one after another, and say so.
|
|
48
|
+
- **Expect a little noise.** A feed tick or a poll landing on a keystroke adds a render to one run and not the other:
|
|
49
|
+
±1 render on an action is noise. A clear result moves more than that, or moves the same way twice.
|
|
50
|
+
- **Watch the recording, not only the totals**: a fix can move work from one root to another. The roots that
|
|
51
|
+
appeared and those that are gone say where it went.
|
|
52
|
+
|
|
53
|
+
## Reading the result
|
|
54
|
+
|
|
55
|
+
`compare_recordings` gives:
|
|
56
|
+
|
|
57
|
+
- **totals** per second and per commit — renders, wasted renders, commits;
|
|
58
|
+
- **roots**: new, gone, and changed by renders per second; `match: 'name'` finds a root the fix moved in the tree;
|
|
59
|
+
- **the same actions** side by side: the median of each time it was done, per character for typing, so the runs need
|
|
60
|
+
not match click for click;
|
|
61
|
+
- **causes** and **plugin metrics**, e.g. a selector's recomputes;
|
|
62
|
+
- **warnings** — read them first: a comparison that warns about the page, the area or partial data is not one.
|
|
63
|
+
|
|
64
|
+
The answer names the change, the scenario and the conditions, then the numbers that moved and by how much — and what
|
|
65
|
+
could have moved them besides the change.
|
package/docs/options.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Options
|
|
2
|
+
|
|
3
|
+
`perfRecorder({ … })` in `vite.config.ts`:
|
|
4
|
+
|
|
5
|
+
| Option | Default | |
|
|
6
|
+
| ------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
7
|
+
| `plugins` | `[]` | [Plugins](plugins.md): `zustand()`, `proxyMemoize()`, `reactQuery()`, or your own |
|
|
8
|
+
| `outDir` | `REACT_PERF_RECORDER_DIR`, then `.agent-artifacts/perf-recorder` | Sessions folder, relative to the root or absolute |
|
|
9
|
+
| `enabled` | dev server only, not under Vitest | |
|
|
10
|
+
| `maxBytes` | 64 MB | Largest request, the final recording included |
|
|
11
|
+
| `retain` | `{ sessions: 100, bytes: 500 MB }` | Oldest sessions go first |
|
|
12
|
+
| `actions` | `{ values: false, secretSelector: '[data-rpr-secret]' }` | `values: true` records typed values; passwords and one-time codes never |
|
|
13
|
+
| `components` | `{ include: ['src/**/*.{tsx,jsx}'], wrappers: ['memo', 'forwardRef', 'createContext'] }` | Adds `displayName` to `const X = memo(…)` and contexts. `wrapperPattern` is only for names an app leaves empty: `^(Anonymous\|ForwardRef\|Memo)$` by default |
|
|
14
|
+
| `panel` | `{ corner: 'bottom-left', highlight: true, shortcuts }` | `false` — engine only |
|
|
15
|
+
| `engine` | `{ bigCommit: 150, timelineLimit: 5000, maxDurationMs: 600000, timers: true }` | `timers: false` leaves `setTimeout`, `setInterval` and `requestAnimationFrame` unwrapped, and timer causes out |
|
|
16
|
+
|
|
17
|
+
Plugin options:
|
|
18
|
+
|
|
19
|
+
- `zustand({ devtools })` — `devtools: false` does not listen to the devtools middleware, so a store update reads
|
|
20
|
+
`<store>.setState` with the keys it changed instead of the action's name.
|
|
21
|
+
- `proxyMemoize({ functions, module, include, exclude })` — `functions` defaults to `['memoize', 'memoizeWithArgs']`.
|
|
22
|
+
- `reactQuery()` — finds the `QueryClientProvider` on the page by itself, also one that mounts late.
|
package/docs/panel.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# The panel and the report
|
|
2
|
+
|
|
3
|
+
## The panel
|
|
4
|
+
|
|
5
|
+
The panel is one row. **● Rec** records; **↺ Page load** reloads the page and records from its first render (the
|
|
6
|
+
area and the followed components survive the reload; `?rpr=rec` does the same from a link). **■ Stop** ends it.
|
|
7
|
+
|
|
8
|
+
Next to them is the area. **⌖ Pick** chooses the part of the page to record — with no area it opens the tree of the
|
|
9
|
+
whole app; a click on the page takes the component under the cursor and opens the tree around it. In the tree `↑`/`↓`
|
|
10
|
+
move, `→` goes inside, `←` goes up, `Enter` keeps the area, `Esc` puts back the one before. Once an area is picked,
|
|
11
|
+
its name opens the tree again, `⧉` copies it as text for an assistant (component, file and line, path, DOM, the
|
|
12
|
+
`scope` for scripts), and `×` goes back to the whole app. `◎` in the tree follows a component by name through the
|
|
13
|
+
recording. While recording the area cannot change, and Pick is hidden.
|
|
14
|
+
|
|
15
|
+
In the header:
|
|
16
|
+
|
|
17
|
+
- **highlights** outlines renders inside the area on the page, while recording and between recordings — green for a
|
|
18
|
+
few in a row, yellow for often, red for all the time, grey where the DOM did not change, dashed for a mount. A
|
|
19
|
+
recording made with it on says so, since drawing costs frame time.
|
|
20
|
+
- **fast** (off by default) works out the reason of a parent-caused render for 50 instances of a component a
|
|
21
|
+
commit, not all of them: about half the cost on lists of thousands. Counts stay exact; ways and cascade trees are
|
|
22
|
+
not recorded.
|
|
23
|
+
|
|
24
|
+
Drag the panel by its header, or the dot it collapses to; it sticks to the nearest edge. In automated browsers
|
|
25
|
+
(`navigator.webdriver`) the panel is hidden unless the URL has `?rpr=panel`.
|
|
26
|
+
|
|
27
|
+
## The report
|
|
28
|
+
|
|
29
|
+
After Stop the report leads with the answer: commits, renders, wasted renders (after which the DOM did not change),
|
|
30
|
+
the slowest action, and the root to fix — the one whose renders changed nothing most often — opened on its reason,
|
|
31
|
+
hook chain and line. Then warnings, actions, the other roots, and:
|
|
32
|
+
|
|
33
|
+
- **Timeline** — tracks over one axis: actions, commits, and a lane per root. A bar is a commit, as wide as React
|
|
34
|
+
took and as tall as it rendered, coloured by its cause; the causes above are the legend and light up their
|
|
35
|
+
commits. Drag across the overview to zoom, drag the tracks to move, the wheel zooms at the pointer. A picked
|
|
36
|
+
commit shows its causes, its roots with their reasons, and its **cascade** as a tree — who rendered whom, through
|
|
37
|
+
which props, the busiest branch first — and outlines its components on the page; the live outlines step aside until
|
|
38
|
+
**Show all**. A picked action lights up every commit it caused and says what it cost.
|
|
39
|
+
- **Components** — every component's renders, and the **ways** its renders came down from their roots: the cause,
|
|
40
|
+
the root and why it rendered, then the props each parent handed on, one link a line. A link with equal props is
|
|
41
|
+
marked: a `memo` there stops the rest of the way.
|
|
42
|
+
- **Memos that miss** — a `useMemo` or `useCallback` that recomputed on at least half of its renders, how often it
|
|
43
|
+
kept its value, the dependency that moved by its name in the code, whether it moved to a new object with the same
|
|
44
|
+
content, and the line.
|
|
45
|
+
- **Plugins** — what the store, query and memoizer plugins saw; a library that is not on the page is left out.
|
|
46
|
+
|
|
47
|
+
The bottom bar keeps the id, **Copy id**, **↻ Repeat**, **Download**, **⤢ Wide** and **Dismiss** in reach however far
|
|
48
|
+
the report is scrolled.
|
|
49
|
+
|
|
50
|
+
## Before → after
|
|
51
|
+
|
|
52
|
+
A second recording of the same page and area is set against the one before it: the same actions side by side, as
|
|
53
|
+
renders per time each was done (per character for typing), so the runs need not match click for click.
|
|
54
|
+
|
|
55
|
+
**↻ Repeat** makes them match anyway: it reloads the page and does the report's actions again at their pace — the
|
|
56
|
+
clicks, the keys, as many characters as were typed — while recording. Events are dispatched the way the browser
|
|
57
|
+
dispatches a person's, so React schedules the work as it did the first time; a step whose element is gone stops
|
|
58
|
+
the replay and says which. For scenarios whose network timing matters, record two scripted runs instead —
|
|
59
|
+
[Measuring a fix](measuring-a-fix.md) has the whole route, a worktree for the change, and how to read the result.
|
package/docs/plugins.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Plugins
|
|
2
|
+
|
|
3
|
+
Three come with the package: `zustand()` names stores and their actions, `proxyMemoize()` counts memoized selectors'
|
|
4
|
+
calls and recomputes, `reactQuery()` turns query cache events into causes. A plugin whose library is not on the page
|
|
5
|
+
is left out of the report.
|
|
6
|
+
|
|
7
|
+
A plugin has two optional halves: build hooks for the dev server and a runtime module for the page.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { definePerfRecorderPlugin } from 'react-perf-recorder/vite';
|
|
11
|
+
|
|
12
|
+
export const myStore = () =>
|
|
13
|
+
definePerfRecorderPlugin({
|
|
14
|
+
name: 'my-store',
|
|
15
|
+
vite: {
|
|
16
|
+
transform(code, id) {
|
|
17
|
+
/* … */
|
|
18
|
+
},
|
|
19
|
+
}, // resolveId / load / transform / config, dev server only
|
|
20
|
+
runtime: { module: '/src/dev/myStorePlugin.ts', options: { verbose: false } },
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/dev/myStorePlugin.ts
|
|
26
|
+
import { definePlugin } from 'react-perf-recorder/runtime';
|
|
27
|
+
|
|
28
|
+
export default definePlugin((options: { verbose: boolean }) => ({
|
|
29
|
+
name: 'my-store',
|
|
30
|
+
setup(ctx) {}, // at page boot, before the app
|
|
31
|
+
describe(fn, kind, next) {
|
|
32
|
+
return null; // a label for a store selector or a store
|
|
33
|
+
},
|
|
34
|
+
start(session) {}, // session.emitCause({ type, changes }) queues a cause for the next commit
|
|
35
|
+
commit(session) {}, // after each commit of a recording: find what mounted late; keep it cheap
|
|
36
|
+
stop(session) {
|
|
37
|
+
// active: false when the library is not on the page; the report then leaves the plugin out
|
|
38
|
+
return { version: 1, active: true, highlights: [], metrics: {} };
|
|
39
|
+
},
|
|
40
|
+
conditions() {
|
|
41
|
+
return { account: 'demo' };
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A cause emitted with `aim: true` after the store told React goes only to the components it updated. A library that
|
|
47
|
+
tells React from a timer of its own, as react-query does, lists its packages (`packages: ['@tanstack/query-core']`)
|
|
48
|
+
and emits with `waitForTimer: true`: the event waits for that timer and goes to the components it updated;
|
|
49
|
+
`merge: key` folds the events of one key into one (`fetch → success ["presence"]`).
|
|
50
|
+
|
|
51
|
+
Helpers for build halves: `proxyModule` replaces a module for the app's imports only, `findDeclarations` +
|
|
52
|
+
`appendLines` name `const X = factory(…)` declarations without shifting lines, `createFilter` matches app files.
|
|
53
|
+
|
|
54
|
+
**A reselect plugin would be** `proxyModule('reselect')` exporting
|
|
55
|
+
`createSelector = instrument(original.createSelector, 'createSelector', 'lastFunction')` from a runtime module with
|
|
56
|
+
`createMemoInstrumentation()`, plus `findDeclarations(code, ['createSelector'])` for names — the same shape as the
|
|
57
|
+
proxy-memoize plugin.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# What a recording holds
|
|
2
|
+
|
|
3
|
+
- **Cascade roots** — a component that rendered while its parent did not: where a render started, how many renders
|
|
4
|
+
it pulled (`perHit`), how many instances fired at once. A component whose parent handed it the same props
|
|
5
|
+
(`children` passed through, equal props to `memo`) is a root of its own.
|
|
6
|
+
- **Reasons**
|
|
7
|
+
- `state now`, `store useChatStore selectPrice`, `context Theme`, `props: value | same: style, onClick`;
|
|
8
|
+
- `SAME-CONTENT` — a new reference with the same content, almost always a subscription bug rather than new data;
|
|
9
|
+
- hook chains, with `[package]` where the app's hooks hand over to a library:
|
|
10
|
+
`useOrderForm › [react-hook-form] useController › useFormState › State @ src/Form.tsx:48`.
|
|
11
|
+
- **Per-component reasons**, renders a parent caused included: `parent: props equal` (a `memo` would skip it),
|
|
12
|
+
`parent: props price | same: style` (what broke `memo`).
|
|
13
|
+
- **Ways and cascades** — for each component, up to three ways its renders came down from a root, up to 20 links
|
|
14
|
+
each; for each commit, its cascade as a tree (the 30 busiest links and those above them).
|
|
15
|
+
- **The app's components apart from the packages'** — told by the file of the element a component rendered, so a UI
|
|
16
|
+
kit's components are found without naming them. The app's lead the report; the picker tree hides package internals
|
|
17
|
+
and providers behind checkboxes.
|
|
18
|
+
- **The page load** — recording can start before the first commit.
|
|
19
|
+
- **Wasted renders and mounts** — renders that changed nothing in the DOM; a component declared inside a render or
|
|
20
|
+
an unstable `key` remounts its subtree every time.
|
|
21
|
+
- **Causes** — what scheduled each commit, aimed at the components it updated: store actions with the keys they
|
|
22
|
+
changed, query events (one per query and commit: `fetch → success ["presence"]`), timers
|
|
23
|
+
(`timer setInterval useCountdown @ src/hooks/useCountdown.ts`), socket and worker messages, navigations, input.
|
|
24
|
+
What none of them explains is read off the stack when React is told: `core:effect @ src/hooks/useSync.ts`.
|
|
25
|
+
- **User actions** — clicks, typing (length only; secrets never), keys, scroll; each with the element, its component
|
|
26
|
+
and file, and the commits it led to. The recording is cut into action → consequences segments: renders per typed
|
|
27
|
+
character, reaction vs background, input latency.
|
|
28
|
+
- **An area** — only what renders inside the picked component; renders from above are kept as outside roots.
|
|
29
|
+
- **Memos** that keep recomputing, long animation frames with their scripts, commit lanes, memoized selectors' calls
|
|
30
|
+
and recomputes.
|
|
31
|
+
|
|
32
|
+
## Sessions
|
|
33
|
+
|
|
34
|
+
`<outDir>/<id>/`:
|
|
35
|
+
|
|
36
|
+
- `session.json` — `status: recording | done | interrupted`, page, area, conditions;
|
|
37
|
+
- `events.ndjson` — streamed while recording, every ~2 s;
|
|
38
|
+
- `recording.json` — written on Stop (`schema: react-perf-recorder/recording`).
|
|
39
|
+
|
|
40
|
+
A session whose page reloads or closes mid-recording stays readable: the MCP server rebuilds a partial recording
|
|
41
|
+
from its events. Hook names, components, ways and plugin sections exist only in the final recording.
|
|
42
|
+
|
|
43
|
+
Every URL a session keeps — the page, its conditions, each navigation — has tokens masked first, in the path, the
|
|
44
|
+
query and the fragment.
|
package/package.json
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-perf-recorder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Record React re-renders in the browser: cascade roots, hook-level reasons, store causes and user actions, saved as sessions an agent reads over MCP",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/deadrime/react-perf-recorder.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://deadrime.github.io/react-perf-recorder/",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/deadrime/react-perf-recorder/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"react",
|
|
17
|
+
"re-render",
|
|
18
|
+
"performance",
|
|
19
|
+
"profiler",
|
|
20
|
+
"vite",
|
|
21
|
+
"vite-plugin",
|
|
22
|
+
"mcp",
|
|
23
|
+
"claude",
|
|
24
|
+
"zustand",
|
|
25
|
+
"react-query",
|
|
26
|
+
"devtools"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"claude",
|
|
32
|
+
"docs/*.md"
|
|
33
|
+
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"react-perf-recorder": "dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"exports": {
|
|
38
|
+
"./vite": {
|
|
39
|
+
"types": "./dist/node/vite.d.ts",
|
|
40
|
+
"import": "./dist/node/vite.js",
|
|
41
|
+
"require": "./dist/node/vite.cjs"
|
|
42
|
+
},
|
|
43
|
+
"./plugins/zustand": {
|
|
44
|
+
"types": "./dist/node/plugins/zustand.d.ts",
|
|
45
|
+
"import": "./dist/node/plugins/zustand.js",
|
|
46
|
+
"require": "./dist/node/plugins/zustand.cjs"
|
|
47
|
+
},
|
|
48
|
+
"./plugins/proxy-memoize": {
|
|
49
|
+
"types": "./dist/node/plugins/proxy-memoize.d.ts",
|
|
50
|
+
"import": "./dist/node/plugins/proxy-memoize.js",
|
|
51
|
+
"require": "./dist/node/plugins/proxy-memoize.cjs"
|
|
52
|
+
},
|
|
53
|
+
"./plugins/react-query": {
|
|
54
|
+
"types": "./dist/node/plugins/react-query.d.ts",
|
|
55
|
+
"import": "./dist/node/plugins/react-query.js",
|
|
56
|
+
"require": "./dist/node/plugins/react-query.cjs"
|
|
57
|
+
},
|
|
58
|
+
"./plugins/zustand/runtime": {
|
|
59
|
+
"types": "./dist/browser/plugins/zustand.d.ts",
|
|
60
|
+
"import": "./dist/browser/plugins/zustand.js"
|
|
61
|
+
},
|
|
62
|
+
"./plugins/proxy-memoize/runtime": {
|
|
63
|
+
"types": "./dist/browser/plugins/proxy-memoize.d.ts",
|
|
64
|
+
"import": "./dist/browser/plugins/proxy-memoize.js"
|
|
65
|
+
},
|
|
66
|
+
"./plugins/react-query/runtime": {
|
|
67
|
+
"types": "./dist/browser/plugins/react-query.d.ts",
|
|
68
|
+
"import": "./dist/browser/plugins/react-query.js"
|
|
69
|
+
},
|
|
70
|
+
"./runtime": {
|
|
71
|
+
"types": "./dist/browser/runtime.d.ts",
|
|
72
|
+
"import": "./dist/browser/runtime.js"
|
|
73
|
+
},
|
|
74
|
+
"./client": {
|
|
75
|
+
"types": "./dist/browser/client.d.ts",
|
|
76
|
+
"import": "./dist/browser/client.js"
|
|
77
|
+
},
|
|
78
|
+
"./engine.iife.js": "./dist/engine.iife.js",
|
|
79
|
+
"./package.json": "./package.json"
|
|
80
|
+
},
|
|
81
|
+
"scripts": {
|
|
82
|
+
"build": "rm -rf dist && tsup",
|
|
83
|
+
"dev": "tsup --watch",
|
|
84
|
+
"typecheck": "tsc --noEmit",
|
|
85
|
+
"test": "vitest run",
|
|
86
|
+
"test:19": "vitest run --config vitest.react19.config.ts",
|
|
87
|
+
"test:matrix": "npm run test && npm run test:19 && npm run test:e2e",
|
|
88
|
+
"test:e2e": "playwright test",
|
|
89
|
+
"fixture": "vite --config test/e2e/fixture-app/vite.config.ts",
|
|
90
|
+
"dev:pages": "vite --config test/e2e/fixture-app/vite.pages.config.ts",
|
|
91
|
+
"build:pages": "NODE_ENV=development vite build --config test/e2e/fixture-app/vite.pages.config.ts",
|
|
92
|
+
"fixture:19": "RPR_REACT=19 FIXTURE_PORT=5392 vite --config test/e2e/fixture-app/vite.config.ts",
|
|
93
|
+
"format": "prettier --write .",
|
|
94
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
95
|
+
},
|
|
96
|
+
"dependencies": {
|
|
97
|
+
"@babel/parser": "^7.29.9"
|
|
98
|
+
},
|
|
99
|
+
"peerDependencies": {
|
|
100
|
+
"react": "^18.2.0 || >=19.1.0",
|
|
101
|
+
"vite": ">=5 <8"
|
|
102
|
+
},
|
|
103
|
+
"peerDependenciesMeta": {
|
|
104
|
+
"react": {
|
|
105
|
+
"optional": true
|
|
106
|
+
},
|
|
107
|
+
"vite": {
|
|
108
|
+
"optional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"engines": {
|
|
112
|
+
"node": ">=18.18"
|
|
113
|
+
},
|
|
114
|
+
"devDependencies": {
|
|
115
|
+
"@jridgewell/trace-mapping": "0.3.31",
|
|
116
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
117
|
+
"@playwright/test": "1.53.2",
|
|
118
|
+
"@tanstack/react-query": "5.81.5",
|
|
119
|
+
"@types/node": "22.14.0",
|
|
120
|
+
"@types/react": "18.3.23",
|
|
121
|
+
"@types/react-dom": "18.3.7",
|
|
122
|
+
"@vitejs/plugin-react-swc": "3.10.2",
|
|
123
|
+
"happy-dom": "20.8.9",
|
|
124
|
+
"marked": "18.0.14",
|
|
125
|
+
"preact": "10.29.8",
|
|
126
|
+
"prettier": "2.8.8",
|
|
127
|
+
"proxy-memoize": "3.0.1",
|
|
128
|
+
"react": "18.3.1",
|
|
129
|
+
"react-dom": "18.3.1",
|
|
130
|
+
"react-hook-form": "7.78.0",
|
|
131
|
+
"react-router-dom": "6.30.3",
|
|
132
|
+
"tsup": "8.5.1",
|
|
133
|
+
"typescript": "5.9.3",
|
|
134
|
+
"vite": "6.4.1",
|
|
135
|
+
"vitest": "4.1.2",
|
|
136
|
+
"zod": "3.25.67",
|
|
137
|
+
"zustand": "4.5.7"
|
|
138
|
+
}
|
|
139
|
+
}
|