rerender-lens 0.4.0 → 0.7.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +208 -359
  3. package/dist/{budget-LkNjGtRc.d.ts → budget-CA1MS7lB.d.cts} +20 -10
  4. package/dist/{budget-COBu7jBU.d.cts → budget-ok1VDJ4t.d.ts} +20 -10
  5. package/dist/cli.cjs +322 -53
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +322 -53
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-BkCct3cJ.d.cts → devtools-Cl5n_52v.d.ts} +17 -2
  10. package/dist/{devtools-CZebzpF6.d.ts → devtools-UwQx2VYS.d.cts} +17 -2
  11. package/dist/index.cjs +537 -49
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +137 -8
  14. package/dist/index.d.ts +137 -8
  15. package/dist/index.js +521 -50
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1690 -0
  18. package/dist/jest-setup.cjs.map +1 -0
  19. package/dist/jest-setup.d.cts +8 -0
  20. package/dist/jest-setup.d.ts +8 -0
  21. package/dist/jest-setup.js +1688 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1777 -0
  24. package/dist/jest.cjs.map +1 -0
  25. package/dist/jest.d.cts +23 -0
  26. package/dist/jest.d.ts +23 -0
  27. package/dist/jest.js +1768 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-B5poAEvE.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-CqQW0Eum.d.ts} +8 -4
  31. package/dist/playwright.cjs +230 -28
  32. package/dist/playwright.cjs.map +1 -1
  33. package/dist/playwright.d.cts +4 -4
  34. package/dist/playwright.d.ts +4 -4
  35. package/dist/playwright.js +230 -28
  36. package/dist/playwright.js.map +1 -1
  37. package/dist/relay.cjs +71 -19
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.d.cts +25 -3
  40. package/dist/relay.d.ts +25 -3
  41. package/dist/relay.js +71 -19
  42. package/dist/relay.js.map +1 -1
  43. package/dist/rerender-lens.iife.js +457 -62
  44. package/dist/runner-report-Bvqoe89M.d.ts +46 -0
  45. package/dist/runner-report-CvpDg2zt.d.cts +46 -0
  46. package/dist/setup.cjs +209 -35
  47. package/dist/setup.cjs.map +1 -1
  48. package/dist/setup.js +209 -35
  49. package/dist/setup.js.map +1 -1
  50. package/dist/{types-BzUEVkxJ.d.cts → types-DwNN5cWe.d.cts} +82 -2
  51. package/dist/{types-BzUEVkxJ.d.ts → types-DwNN5cWe.d.ts} +82 -2
  52. package/dist/vite.d.cts +1 -1
  53. package/dist/vite.d.ts +1 -1
  54. package/dist/vitest-setup.cjs +357 -44
  55. package/dist/vitest-setup.cjs.map +1 -1
  56. package/dist/vitest-setup.d.cts +4 -4
  57. package/dist/vitest-setup.d.ts +4 -4
  58. package/dist/vitest-setup.js +357 -44
  59. package/dist/vitest-setup.js.map +1 -1
  60. package/dist/vitest.cjs +420 -80
  61. package/dist/vitest.cjs.map +1 -1
  62. package/dist/vitest.d.cts +21 -39
  63. package/dist/vitest.d.ts +21 -39
  64. package/dist/vitest.js +421 -81
  65. package/dist/vitest.js.map +1 -1
  66. package/package.json +41 -4
  67. package/panel/panel.css +49 -0
  68. package/panel/panel.js +871 -353
package/README.md CHANGED
@@ -1,449 +1,299 @@
1
1
  # rerender-lens
2
2
 
3
- Find avoidable React re-renders and see exactly what caused them: which prop, which state,
4
- which context, and which ancestor started the update.
3
+ Find avoidable React re-renders and see exactly what caused them: which prop, state or context,
4
+ which ancestor started the cascade, and the fix.
5
5
 
6
- It reads the fiber tree after every commit through the same global hook React DevTools uses.
7
- Nothing in React is patched or wrapped, so it works with Fast Refresh, `React.memo` comparators,
8
- `forwardRef`, class components, the automatic JSX runtime, and any bundler.
6
+ It reads the fiber tree after each commit through the same global hook React DevTools uses.
7
+ Nothing in React is patched, so it works with Fast Refresh, `React.memo`, `forwardRef`, class
8
+ components and any bundler.
9
9
 
10
10
  ```
11
11
  ▸ [rerender-lens] <ProductRow> avoidable re-render: 1 equal by value, 1 new function
12
12
  - caused by <ProductPage> re-rendering (its state changed).
13
- - prop "style" is a new reference but deep-equal to the previous value: memoize the object with useMemo, or hoist it to module scope if it is constant.
14
- - prop "onSelect" is a new function instance on every render: wrap it in useCallback (or hoist it out of the parent's render).
13
+ - prop "style" is a new reference but deep-equal to the previous value: memoize it with useMemo, or hoist it.
14
+ - prop "onSelect" is a new function instance on every render: wrap it in useCallback.
15
15
  at App > ProductPage > ProductRow
16
16
  ```
17
17
 
18
- Two ways to use it: the **DevTools extension** (a "Re-renders" panel next to Elements and
19
- Console, no app code needed) or the **npm package** (console output, test assertions, and the
20
- bridge the extension reads).
18
+ ## Pick a way in
21
19
 
22
- ## Chrome extension
23
-
24
- The extension adds a **Re-renders** panel to DevTools: a component tree with avoidable counts,
25
- why each component rendered, which ancestor started the cascade, the props and hooks that
26
- changed, and the fix as a snippet you can copy. Other views rank components by wasted renders
27
- (Offenders), group renders by React commit with their root cause (Commits), and rank every fix
28
- by how many re-renders it removes (Fixes). It also gives you an Elements-panel sidebar for the
29
- selected node, a badge with the avoidable count of the tab, hover-to-highlight in the page, and
30
- "open source" links into the Sources panel.
31
-
32
- ### Install
33
-
34
- Until the Web Store listing is live, install it unpacked:
35
-
36
- 1. Download `rerender-lens-chrome-<version>.zip` from the
37
- [latest release](https://github.com/NexaLeaf/rerender-lens/releases) and unzip it, or build it
38
- from a clone with `npm install && npm run build` and use the `extension/` folder.
39
- 2. Open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked**, pick the folder.
40
- 3. Open your app, open DevTools, pick the **Re-renders** tab.
41
-
42
- Edge loads the same folder from `edge://extensions`. Firefox 128+ uses the `firefox` zip from the
43
- release, via `about:debugging`.
44
-
45
- ### Connect a page
46
-
47
- Local development hosts (`localhost`, `127.0.0.1`, `*.localhost`, `*.local`) work out of the box.
48
- Any other site: click the toolbar icon and **Enable on this site** (a one-time host permission
49
- for that origin only).
50
-
51
- Then pick one of two modes:
52
-
53
- - **Inject the library** (no app code): tick *Inject the library* in the toolbar popup or in the
54
- panel's Settings and reload. The extension loads rerender-lens into the page before React,
55
- tracking every `React.memo` / `PureComponent` by default, without the hook/context/state
56
- snapshots (*Include state* in Settings turns them on; they are the costliest part on big
57
- apps). Change what is tracked from Settings; the choice is saved per origin.
58
- - **The page runs the library**: install the package and pass the DevTools notifier (see below).
59
- This is the way to go when you also want console output or want to commit the setup.
20
+ | You have | Do this | You get |
21
+ | --- | --- | --- |
22
+ | Any React app, no code changes | Install the [DevTools extension](#devtools-extension) | A **Re-renders** panel in DevTools |
23
+ | A Vite app | `rerenderLens()` in `vite.config.ts` | Console output, the panel at `/__rerender-lens/`, no extension needed |
24
+ | Next.js, Webpack, anything else | `import 'rerender-lens/setup'` + `npx rerender-lens panel` | The same panel in any browser tab |
25
+ | Tests | `rerender-lens/vitest`, `rerender-lens/jest` or `rerender-lens/playwright` | Failing tests and CI budgets for avoidable re-renders |
26
+
27
+ ## DevTools extension
28
+
29
+ Adds a **Re-renders** tab: the component tree with avoidable counts, why each component rendered,
30
+ the props and hooks that changed, and the fix as a snippet. Other views rank components by wasted
31
+ renders (Offenders), group renders by React commit with their root cause (Commits), rank every fix
32
+ by how many re-renders it removes (Fixes), and compare a recording before and after a fix
33
+ (Sessions). A timeline strip above the tree draws one bar per commit sized by its renders and
34
+ coloured by its avoidable share: click a bar for that commit, drag across bars to brush a time
35
+ window that every view narrows to. Also: an Elements-panel sidebar, a badge with the tab's
36
+ avoidable count, hover to highlight in the page, "open source" links, and the same panel next to
37
+ the page (side panel) or in its own window.
38
+
39
+ **Install** (until the Web Store listing is live): download `rerender-lens-chrome-<version>.zip`
40
+ from the [latest release](https://github.com/NexaLeaf/rerender-lens/releases), unzip it, open
41
+ `chrome://extensions`, turn on *Developer mode*, *Load unpacked*, pick the folder. Edge loads the
42
+ same folder; Firefox 128+ uses the `firefox` zip via `about:debugging`.
43
+
44
+ **Connect a page.** Local hosts (`localhost`, `127.0.0.1`, `*.localhost`, `*.local`) work out of
45
+ the box; for any other site click the toolbar icon and *Enable on this site*. Then either tick
46
+ *Inject the library* (the extension loads rerender-lens before React; no app code) or let the page
47
+ run the library itself:
60
48
 
61
49
  ```ts
62
50
  import { init, createDevtoolsNotifier } from 'rerender-lens';
63
51
  init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
64
52
  ```
65
53
 
66
- Production React builds are detected and flagged (names may be minified, hooks unlabeled). If
67
- React DevTools is also installed and its Components tab comes up empty with injection on, tick
68
- *Let React DevTools create the hook* for that origin.
69
-
70
- `extension/README.md` has the details: keyboard shortcuts, how the transport works, Firefox and
71
- Edge packaging, and the store listing.
72
-
73
- ## Install the package
74
-
75
- ```sh
76
- npm i -D rerender-lens
77
- ```
54
+ Injection tracks every `memo` / `PureComponent` and leaves the hook/context/state snapshots off
55
+ (*Include state* in Settings turns them on; they are the costliest part on large apps). Settings
56
+ are saved per origin. Details, shortcuts and packaging: [`extension/README.md`](extension/README.md).
78
57
 
79
- Peer dependency: `react >= 16.8`. Tested with React 18 and 19.
58
+ ### Nothing shows up?
80
59
 
81
- ## One-line setup per bundler
60
+ Most often the default is doing exactly what it says: injection tracks only `React.memo` components
61
+ and `PureComponent` classes, and an app of plain function components has none. The panel's empty
62
+ state says so in words — *"tracking every React.memo and PureComponent; 42 components rendered, 3 of
63
+ them tracked"* — with two buttons: **Track every component** and **Track components matching…**.
64
+ For one component, select its element in **Elements**: the *rerender-lens* sidebar gives the verdict,
65
+ the reason, and a *Track this component* button. Nothing avoidable in a tracked component is the
66
+ other, good, case: it simply has not re-rendered.
82
67
 
83
- **Vite** (recommended for Vite users: no extension injection, no permissions, one line):
68
+ ## Vite
84
69
 
85
70
  ```ts
86
71
  // vite.config.ts
87
72
  import { rerenderLens } from 'rerender-lens/vite';
88
-
89
- export default defineConfig({
90
- plugins: [react(), rerenderLens({ trackAllMemoized: true })],
91
- });
73
+ export default defineConfig({ plugins: [react(), rerenderLens({ trackAllMemoized: true, panel: true })] });
92
74
  ```
93
75
 
94
- The plugin injects a module before your entry in dev (`vite build` is untouched) that calls
95
- `init` with your options plus the DevTools notifier. Matchers can be strings or RegExps;
96
- `devtools: false` skips the bridge, `applyInBuild: true` keeps it in builds, `pages: ['/']`
97
- limits which HTML pages get it.
98
-
99
- **No extension at all**: add `panel: true` and open `http://localhost:5173/__rerender-lens/` in a
100
- second tab. The dev server serves the same panel the extension uses; the app publishes reports on
101
- a same-origin `BroadcastChannel` and the panel sends settings and highlight commands back over it.
102
- Panel state lives in `localStorage`. (`channel` renames the channel, `panel: '/some/path/'` moves
103
- the mount.)
76
+ Dev only (`vite build` is untouched). `panel: true` serves the panel at
77
+ `http://localhost:5173/__rerender-lens/`; the app and the panel talk over a same-origin
78
+ `BroadcastChannel`, so no extension is needed. `pages: ['/']` limits which HTML pages get the
79
+ setup script; `devtools: false` skips the bridge.
104
80
 
105
- **Next.js** (App or Pages router): run it before React from the client instrumentation file:
81
+ ## Next.js, Webpack, anything
106
82
 
107
83
  ```ts
108
- // instrumentation-client.ts
84
+ // Next.js: instrumentation-client.ts // Webpack / Rspack: entry: ['rerender-lens/setup', './src/index.tsx']
109
85
  import 'rerender-lens/setup';
110
86
  ```
111
87
 
112
- **Webpack / Rspack / others**: put the setup entry first:
113
-
114
- ```js
115
- entry: ['rerender-lens/setup', './src/index.tsx'],
116
- ```
117
-
118
- `rerender-lens/setup` is a side-effect module that calls
119
- `init({ trackAllMemoized: true, notifier: createDevtoolsNotifier() })` unless
120
- `process.env.NODE_ENV === 'production'` or the page already runs the library. Use `configure()`
121
- afterwards to change options, or the extension's Settings.
122
-
123
- **The panel for any app, no extension, no Vite** (Next.js, Webpack, a remote dev box, a phone):
88
+ The setup entry starts the library with every `memo` / `PureComponent` tracked, outside
89
+ production builds. To see the panel without the extension:
124
90
 
125
91
  ```sh
126
- npx rerender-lens panel # http://127.0.0.1:4141/ serves the panel and relays messages
92
+ npx rerender-lens panel # http://127.0.0.1:4141/ (--port, --host 0.0.0.0 for another machine)
127
93
  ```
128
94
 
129
- Point the app at it and open the printed URL in any browser:
130
-
131
- ```ts
132
- init({ trackAllMemoized: true, notifier: createDevtoolsNotifier({ relay: 'http://127.0.0.1:4141' }) });
133
- // or, with rerender-lens/setup: RERENDER_LENS_RELAY=http://127.0.0.1:4141 (NEXT_PUBLIC_RERENDER_LENS_RELAY for Next.js)
134
- // or, before the app loads: window.__RERENDER_LENS_RELAY__ = 'http://127.0.0.1:4141'
135
- ```
95
+ On loopback it needs no secret. Given any other `--host` it generates a token, prints it in the
96
+ URL, and refuses connections without it, because reports carry your app's prop, state and context
97
+ values. Pass the whole printed URL (token and all) to the app and the panel; `--token` sets your
98
+ own and `--no-token` opts out.
136
99
 
137
- The app streams commands in over server-sent events and posts hello, reports and replies back; the
138
- panel connects the same way (`panel.html?relay=…`), so Settings, highlight and replay all work.
139
- Several apps or panels can share one relay; the panel re-attaches when an app reloads.
140
- `--port` and `--host` change where it listens (`--host 0.0.0.0` for another machine).
100
+ and point the app at it with `RERENDER_LENS_RELAY=http://127.0.0.1:4141`
101
+ (`NEXT_PUBLIC_RERENDER_LENS_RELAY` for Next.js), `createDevtoolsNotifier({ relay })`, or
102
+ `window.__RERENDER_LENS_RELAY__` set before the app loads. Settings, highlight and replay travel
103
+ back to the app, and the panel re-attaches when an app reloads. Point **several apps** at one relay
104
+ (a host app and a microfrontend, two pages, two machines) and the panel shows a picker listing each
105
+ by address; it watches one at a time and commands go only to that one.
141
106
 
142
- ## Setup
107
+ ## Manual setup
143
108
 
144
109
  ```ts
145
- // src/rerender-lens.ts
146
110
  import { init } from 'rerender-lens';
147
-
148
- if (import.meta.env.DEV) {
149
- init({ trackAllMemoized: true });
150
- }
151
- ```
152
-
153
- ```ts
154
- // src/main.tsx
155
- import './rerender-lens';
156
- import { createRoot } from 'react-dom/client';
157
- ...
111
+ if (import.meta.env.DEV) init({ trackAllMemoized: true, include: [/^Grid/, 'Sidebar'], exclude: ['DevOverlay'] });
158
112
  ```
159
113
 
160
- Import the setup file before `react-dom`. React DOM looks for the DevTools hook once, when its
161
- module loads; `init` creates the hook if nothing else did. If the React DevTools extension is
162
- installed, or Vite's Fast Refresh preamble runs, the hook already exists and order does not matter.
114
+ Import it before `react-dom` (React looks for the DevTools hook once, when it loads). Mark single
115
+ components with `track(Comp)` or `Comp.rerenderLens = true`; `configure()` changes options at
116
+ runtime. Inside a library or Storybook, `useWhyRerender('Row', { ...props, theme })` reports one
117
+ component without `init`.
163
118
 
164
- No `jsxImportSource`, no default-import requirement, no Babel plugin.
119
+ ## Tests and CI
165
120
 
166
- ## Choosing what to track
121
+ **Vitest**, whole suite, two config lines:
167
122
 
168
123
  ```ts
169
- init({
170
- trackAllMemoized: true, // every React.memo and PureComponent
171
- include: [/^Grid/, 'Sidebar'], // by display name: string, RegExp or predicate
172
- exclude: ['DevOverlay'],
173
- });
174
- ```
175
-
176
- Or mark a component:
177
-
178
- ```ts
179
- import { track } from 'rerender-lens';
180
-
181
- export const ProductRow = track(function ProductRow(props: Props) { ... });
182
- export default track(memo(Sidebar));
183
- export const Cell = track((props: CellProps) => ..., 'Cell'); // name for anonymous arrows
184
- ```
185
-
186
- Marking sets the static `rerenderLens = true`; you can also set it by hand. `configure()`
187
- changes any option at runtime without remounting anything.
188
-
189
- ## What a report contains
190
-
191
- Every update of a tracked component produces a `RenderReport`:
192
-
193
- | Field | Meaning |
194
- | --- | --- |
195
- | `component` | display name |
196
- | `trigger` | `props`, `parent`, `state`, `hooks` or `mixed` |
197
- | `avoidable` | `true` when nothing genuinely changed |
198
- | `propChanges` | one entry per changed prop with `path`, `kind`, `prev`, `next` |
199
- | `stateChanges` | class components: `this.state` diff |
200
- | `hookChanges` | `useState`, `useReducer`, `useSyncExternalStore` and `useContext` values that changed; context entries carry `provider` (who renders it) and `changedKeys` / `totalKeys` for object values |
201
- | `hookState`, `contexts`, `state` | current value of every state hook, every context read, and class `this.state` (off with `includeState: false`) |
202
- | `updaters` | components that scheduled the commit (`setState`, dispatch), from React's updater tracking |
203
- | `commitCause`, `afterCommit` | `effect-after-commit` (an effect of the previous commit set state) or `suspense-resolved` |
204
- | `key` | the element's `key`, when it has one |
205
- | `parent` | nearest ancestor that rendered in the same commit, and why |
206
- | `owner` | component that created the element (dev builds) |
207
- | `path` | component ancestry from the root |
208
- | `instanceId`, `renderCount` | stable per mounted instance |
209
- | `memoized` | `React.memo` / `PureComponent`: props alone decide whether it re-renders |
210
- | `selfDuration`, `treeDuration` | own render time, and with everything below that rendered (dev/profiling builds) |
211
- | `commitId` | shared by every report of one React commit |
212
- | `commitPriority` | `immediate` (clicks, keys), `user-blocking` (continuous input), `normal` (transitions, async), `low`, `idle` |
213
- | `source` | file, line and column where the element was created (dev builds) |
214
- | `reasons` | human-readable explanations with the fix |
215
-
216
- Change kinds:
217
-
218
- | `kind` | Means | Fix |
219
- | --- | --- | --- |
220
- | `deep-equal` | new reference, same contents | `useMemo`, or hoist a constant |
221
- | `function` | new function, same body | `useCallback` |
222
- | `element` | new element, same type and props | `useMemo` the element or pass it as `children` |
223
- | `different` | a real change | none needed |
224
- | `added` / `removed` | key appeared or disappeared | usually a real change |
225
-
226
- A `parent` trigger with no changes at all means: identical props, an ancestor re-rendered.
227
- Wrap the component in `React.memo`.
228
-
229
- By default only avoidable re-renders are printed; pass `logAll: true` to print every report.
230
- The `notifier` always receives every report.
231
-
232
- ## Use in tests
233
-
234
- ```ts
235
- import { init, disable, createCollector } from 'rerender-lens';
236
-
237
- const collector = createCollector();
238
- beforeAll(() => init({ trackAllMemoized: true, silent: true, notifier: collector.notifier }));
239
- afterAll(disable);
240
- beforeEach(collector.clear);
241
-
242
- test('typing in the search box does not re-render the grid rows', async () => {
243
- render(<ProductPage />);
244
- await user.type(screen.getByRole('searchbox'), 'abc');
245
- collector.assertNoAvoidable(); // throws listing every component and reason
246
- // or: expect(collector.avoidable).toHaveLength(0)
247
- });
248
- ```
249
-
250
- In Vitest or Jest, call `ensureDevtoolsHook()` (or `init`) from a `setupFiles` entry so the hook
251
- exists before `react-dom` is imported by your tests.
252
-
253
- ### Vitest, whole suite
254
-
255
- Two config lines collect every avoidable re-render across the run and print the ranked fixes at
256
- the end; a budget file turns it into a gate:
257
-
258
- ```ts
259
- // vitest.config.ts
260
124
  test: {
261
125
  setupFiles: ['rerender-lens/vitest/setup'],
262
- reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json', exportTo: 'rerender-lens.json' }]],
126
+ reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json' }]],
263
127
  }
264
128
  ```
265
129
 
266
- The setup entry starts the library (all memoized components, silent) in every test file and hands
267
- the file's reports to the reporter; the reporter prints `rerender-lens: N reports, M avoidable`
268
- with the fixes, fails the run on budget violations, and can write a panel-compatible export
269
- (`Sessions > Import` in the extension). For other options, or per-test assertions, write your own
270
- setup file:
130
+ The reporter prints the run's ranked fixes and root causes, fails on budget violations, and can
131
+ write a panel-compatible export. Custom options: `setupRerenderLens(options, { afterAll })`.
271
132
 
272
- ```ts
273
- import { afterAll } from 'vitest';
274
- import { setupRerenderLens } from 'rerender-lens/vitest';
275
- export const lens = setupRerenderLens({ include: [/^Grid/], failFast: true }, { afterAll });
276
- // lens.collector.assertNoAvoidable() inside a test
133
+ **Jest**, the same two lines (`setupFilesAfterEnv`, not `setupFiles`: the hook needs `afterAll`):
134
+
135
+ ```js
136
+ // jest.config.js
137
+ module.exports = {
138
+ testEnvironment: 'jsdom',
139
+ setupFilesAfterEnv: ['rerender-lens/jest/setup'],
140
+ reporters: ['default', ['rerender-lens/jest', { budget: 'rerender-budget.json' }]],
141
+ };
277
142
  ```
278
143
 
279
- ### Playwright, real browser
144
+ Same options and same output as the Vitest reporter; a budget violation fails the run.
145
+
146
+ **Playwright**, real browser:
280
147
 
281
148
  ```ts
282
149
  import { installRerenderLens, pullReports, expectWithinBudget } from 'rerender-lens/playwright';
283
-
284
- test('search does not re-render the grid', async ({ page }) => {
285
- await installRerenderLens(page, { include: ['ProductRow'] }); // before goto: runs before React
286
- await page.goto('/products');
287
- await page.getByRole('searchbox').fill('abc');
288
- expectWithinBudget(await pullReports(page), { '*': 0 }); // throws with the ranked fixes
289
- });
150
+ await installRerenderLens(page, { include: ['ProductRow'] }); // before page.goto
151
+ await page.goto('/products');
152
+ expectWithinBudget(await pullReports(page), { '*': 0 }); // throws with the ranked fixes
290
153
  ```
291
154
 
292
- `installRerenderLens` injects the same bundle the extension uses at document start, so it works on
293
- any build the browser can load (production builds are flagged; names may be minified). Pass
294
- `relay: 'http://127.0.0.1:4141'` to watch the run in the panel, `clearReports(page)` between
295
- scenarios.
296
-
297
- ### Budgets and comparisons in CI
155
+ **Any test runner**, per test:
298
156
 
299
157
  ```ts
300
- // allow known offenders, fail on anything new or worse
301
- collector.assertWithinBudget(JSON.parse(readFileSync('rerender-budget.json', 'utf8')));
302
- // produce the baseline once: writeFileSync('rerender-budget.json', JSON.stringify(toBudget(collector.reports)))
303
- collector.fixes(); // ranked fixes, most re-renders removed first
304
- collector.summary('after'); // a session summary, comparable with compareSummaries()
158
+ const collector = createCollector();
159
+ beforeAll(() => init({ trackAllMemoized: true, silent: true, notifier: collector.notifier }));
160
+ test('search does not re-render the rows', () => { /* ... */ collector.assertNoAvoidable(); });
305
161
  ```
306
162
 
307
- The `rerender-lens` CLI does the same with files from the extension (Export) or from `summary()`:
163
+ **CLI**, on a panel export or a session summary:
308
164
 
309
165
  ```sh
310
- npx rerender-lens fixes export.json # ranked fixes
311
- npx rerender-lens summary export.json --out before.json
312
- npx rerender-lens compare before.json after.json # per-component deltas; exit 1 on regressions
166
+ npx rerender-lens fixes export.json # ranked fixes, then root causes
167
+ npx rerender-lens causes export.json # which component started each cascade
313
168
  npx rerender-lens budget export.json --init > rerender-budget.json
314
- npx rerender-lens budget export.json rerender-budget.json # exit 1 when a component exceeds its budget
315
- npx rerender-lens panel [--port 4141] [--host 127.0.0.1] # the panel + relay for any app (see above)
169
+ npx rerender-lens budget export.json rerender-budget.json # exit 1 when a component exceeds its budget
170
+ npx rerender-lens summary export.json --out before.json && npx rerender-lens compare before.json after.json
316
171
  ```
317
172
 
318
- ## Track a single component from the inside
319
-
320
- Works anywhere without `init`, for example inside library packages or Storybook:
321
-
322
- ```ts
323
- import { useWhyRerender } from 'rerender-lens';
324
-
325
- function Row(props: RowProps) {
326
- const theme = useContext(ThemeContext);
327
- useWhyRerender('Row', { ...props, theme });
328
- ...
329
- }
330
- ```
173
+ ## What a report says
331
174
 
332
- Pass whatever values you want compared.
175
+ Every re-render of a tracked component is a `RenderReport`. The fields you will read:
333
176
 
334
- ## DevTools bridge
335
-
336
- ```ts
337
- import { createDevtoolsNotifier } from 'rerender-lens';
338
-
339
- init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
340
- ```
341
-
342
- Each report is serialized (functions become `ƒ name`, elements `<Type>`, cycles cut) and posted
343
- on `window` as `{ __rerenderLens: true, version: 2, type: 'report', payload }`. Reports go on
344
- `window` only after a listener announced itself (`{ __rerenderLensReady: true }`, which the
345
- extension's content script posts) or after `replay()`, so a page nobody inspects pays no
346
- structured clone per report. Values are bounded: 100 entries per array/object/Map/Set, depth 4
347
- by default (`maxDepth`), 20k nodes per report. The last 300 reports are buffered. The bridge
348
- at `window.__RERENDER_LENS_DEVTOOLS__` exposes:
349
-
350
- | Method | |
177
+ | Field | Meaning |
351
178
  | --- | --- |
352
- | `replay()` / `clear()` | re-post or drop the buffer |
353
- | `pull(since)` | reports newer than a sequence number, for panels that poll instead of listening |
354
- | `info()` | library version, protocol, React renderers (version, dev/prod), current options |
355
- | `configure(options)` / `getOptions()` | change options at runtime; matchers as strings (`"/^Grid/"`) |
356
- | `highlight(instanceId)` / `flashAvoidable(on)` | outline a component's DOM in the page, or flash avoidable renders |
357
- | `inspect(node)` | component, instance id and recent reports for a DOM node (DevTools `$0`) |
179
+ | `component`, `path`, `owner` | display name, ancestry from the root, who created the element |
180
+ | `trigger` | `props`, `parent`, `state`, `hooks` or `mixed` |
181
+ | `avoidable` | `true` when nothing genuinely changed |
182
+ | `propChanges`, `stateChanges`, `hookChanges` | what changed: `path`, `kind`, `prev`, `next`; context entries name the provider and the changed keys |
183
+ | `hookState`, `contexts`, `state` | every hook, context and class state value (`includeState`) |
184
+ | `parent`, `updaters` | the ancestor that rendered in the same commit and why; the components that scheduled the commit |
185
+ | `commitId`, `commitPriority`, `commitCause` | one id per React commit; discrete input / transition / idle; `effect-after-commit` or `suspense-resolved` |
186
+ | `selfDuration`, `treeDuration`, `source` | render time (dev/profiling builds); file, line and column of the element |
187
+ | `reasons` | the explanations with the fix, as printed |
358
188
 
359
- The DevTools extension consumes this (see [Chrome extension](#chrome-extension) above); with
360
- injection enabled it needs no `init` call at all.
189
+ | `kind` | Means | Fix |
190
+ | --- | --- | --- |
191
+ | `deep-equal` | new reference, same contents | `useMemo`, or hoist a constant |
192
+ | `function` | new function, same body | `useCallback` |
193
+ | `element` | new element, same type and props | `useMemo` the element or pass it as `children` |
194
+ | `different` | a real change | none |
361
195
 
362
- Every report also carries `commitId` (shared by all reports of one React commit) and, in dev
363
- builds, `source` (where the element was created).
196
+ A `parent` trigger with no changes means identical props and an ancestor re-rendered: wrap the
197
+ component in `React.memo`.
364
198
 
365
- ## API
199
+ ## What counts as avoidable
366
200
 
367
- ```ts
368
- init(options?): () => void // returns disable
369
- configure(options): void // merge options at runtime
370
- disable(): void
371
- isEnabled(): boolean
372
- track(component, name?): component
373
- ensureDevtoolsHook(): hook // create the global hook early (test setup files)
374
- // 'rerender-lens/vite': rerenderLens(options), renderSetupModule(options)
375
- // 'rerender-lens/setup': side-effect entry (init with defaults outside production)
376
- // 'rerender-lens/relay': createRelayServer({ port?, host? }) — what `rerender-lens panel` runs
377
- // 'rerender-lens/playwright': installRerenderLens(page, options?), pullReports(page), clearReports(page), expectWithinBudget(reports, budget)
378
- // 'rerender-lens/vitest': setupRerenderLens(options?, { afterAll }), default reporter ({ budget?, exportTo?, limit? }); 'rerender-lens/vitest/setup'
379
- useWhyRerender(name, values, options?)
380
- createCollector(): { reports, avoidable, notifier, clear, assertNoAvoidable, fixes, summary, assertWithinBudget }
381
- rankFixes(reports), formatFixes(reports) // ranked fixes
382
- summarizeReports(reports), compareSummaries(a, b), formatComparison(c), parseExport(json)
383
- checkBudget(reports, budget), toBudget(reports), assertWithinBudget(reports, budget)
384
- combineNotifiers(...notifiers): Notifier
385
- createDevtoolsNotifier({ bufferSize?, target?, maxDepth?, flashAvoidable?, channel?, relay? }): Notifier
386
- getRenderers(), isProductionReact() // what react-dom registered on the DevTools hook
387
- serializeOptions(o), deserializeOptions(o) // Options <-> JSON-safe form used by the bridge
388
- VERSION
389
- deepEqual(a, b), diffRecords(prev, next), classify(prev, next)
390
- ```
201
+ `avoidable` is true when the render produced no genuine change in props, state or hooks *and*
202
+ nothing about the situation explains the render away. The rules, on the shapes modern apps produce
203
+ (each row is a test in `test/modern.test.ts` and a card on the example's `/modern.html`):
391
204
 
392
- `Options`:
205
+ | Situation | Verdict | Why / fix |
206
+ | --- | --- | --- |
207
+ | Parent re-rendered, identical props, plain function or class component | avoidable, `parent` | wrap in `React.memo`; classes: extend `PureComponent` or implement `shouldComponentUpdate` |
208
+ | Same, but the component is compiled by React Compiler (`compiled: true`) | **not avoidable** | React still calls it, but its output comes from the memo cache and the render is cheap; a compiled app re-renders every component on a parent update and flagging them all would be noise. A new-but-equal prop (`deep-equal`, `function`, `element`) still misses the cache and stays avoidable, with the upstream fix and no `React.memo` advice |
209
+ | `memo` / `PureComponent` / `shouldComponentUpdate → false` with equal props | no report | React bailed out; the component never rendered |
210
+ | Inline object, array or element prop (`deep-equal`, `element`) | avoidable | `useMemo`, hoist constants, pass static elements as `children` from a stable parent |
211
+ | Inline callback or render prop (`function`), e.g. `renderItem={(x) => …}` on a memo list | avoidable | `useCallback` or hoist it. **Limitation:** functions are compared by name and source, so a new closure with the same text that captures a *changed* value is still `function` and counted as avoidable, even though its output differs |
212
+ | `ref` is a new `{ current }` object on every render (React 19 keeps `ref` in props; forwardRef and memo see it) | avoidable, `deep-equal` on `ref` | `useRef` (or `createRef` outside the render); `.current` is ignored because React mutates it when it re-attaches the ref. An inline callback ref is `function`: `useCallback` |
213
+ | `useSyncExternalStore` / store selector returning a new object with equal contents | avoidable, hook `deep-equal` | store-specific advice: return a stored slice, `shallowEqual` / `createSelector` (Redux), `useShallow` (Zustand), or cache the snapshot. A selector returning a primitive does not render at all |
214
+ | `useState`/`setState` with a value deep-equal to the current one | avoidable | reuse the existing object or bail out before calling the setter |
215
+ | A genuine prop, state, context or store change | not avoidable (`props` / `state` / `hooks` / `mixed`) | the change is listed with its path |
216
+ | `startTransition(() => setState(…))` | parent: `state` at `commitPriority: 'normal'`, not avoidable; memo children with equal props: no report | nothing to fix |
217
+ | Content revealed by a Suspense boundary (`use(promise)` resolved, `React.lazy` loaded) | **not avoidable**, `commitCause: 'suspense-resolved'` | React re-renders the content it kept hidden while the fallback was shown; the component that suspended has a genuinely new promise (`props`). Components outside the boundary in the same commit keep the usual verdict |
218
+ | The deferred second render of `useDeferredValue` (dev builds) | not avoidable, `hooks` with a `useDeferredValue` change | React's catch-up render; it is also never mistaken for an effect → setState loop. `useId` and a stable deferred input add no hook changes |
219
+ | Mount, StrictMode's double render, a Fast Refresh commit | no report | never reported (StrictMode is one commit) |
220
+
221
+ ## Options
393
222
 
394
223
  | Option | Default | |
395
224
  | --- | --- | --- |
396
225
  | `trackAllMemoized` | `false` | track every `memo` / `PureComponent` |
397
226
  | `trackAllComponents` | `false` | track everything (noisy) |
398
- | `include` / `exclude` | | display-name matchers |
227
+ | `include` / `exclude` | | display-name matchers: string, RegExp or predicate |
399
228
  | `trackHooks` | `true` | diff hook state and contexts |
400
- | `includeState` | `true` (`false` when injected by the extension) | put every hook, context and class state value on each report; the costliest option on large trees |
401
- | `resolveHookNames` | `false` | label hooks with the custom hooks that own them (`useCart › useState#0`); re-runs each component type once |
229
+ | `includeState` | `true` (`false` when injected) | put every hook, context and class state value on each report; the costliest option on large trees |
230
+ | `resolveHookNames` | `false` | label hooks with the custom hooks that own them (`useCart › useState#0`) |
402
231
  | `logAll` | `false` | print non-avoidable reports too |
403
- | `silent` | `false` | never print; notifier still runs |
232
+ | `silent` | `false` | never print; the notifier still runs |
404
233
  | `notifier` | | receives every `RenderReport` |
405
- | `collapse` | `true` | `console.groupCollapsed` vs `console.group` |
406
- | `console` | `console` | sink for printing |
407
234
  | `ignoreHotReload` | `true` | skip commits caused by Fast Refresh |
408
235
  | `maxReportsPerComponent` | `0` | stop printing a component after N reports |
236
+ | `collapse`, `console` | `true`, `console` | console group style and sink |
409
237
 
410
- ## How it works, and the caveats that follow
411
-
412
- - After each commit, the fiber tree is walked from the root, skipping subtrees React bailed out
413
- of. A component counts as re-rendered when React set its `PerformedWork` flag. Props, class
414
- state, hook state nodes and context dependencies are compared against the fiber's alternate.
415
- - Hook labels come from React's dev-only hook type list when it maps one-to-one onto the state
416
- nodes; otherwise nodes are labelled by inspection (`useState`, `useReducer`,
417
- `useSyncExternalStore`, or `state` for internal nodes of `useTransition` and friends).
418
- - A commit in which Fast Refresh swapped a component's code is skipped entirely
419
- (`ignoreHotReload`). The edited component's children re-render with identical props during
420
- that commit, which would otherwise look like avoidable re-renders.
421
- - The mount render is never reported. StrictMode's double render happens inside one commit and
422
- is reported once.
423
- - Everything runs during React's commit callback, synchronously. It is meant for development;
424
- keep it out of production builds.
425
- - Fiber field names have been stable since React 16.9 (`flags` was `effectTag` before 17; both
426
- are handled). Future React versions may change internals; the walk is wrapped so a failure
427
- logs one warning instead of breaking the app.
428
-
429
- ## Example app
430
-
431
- `examples/vite-react/scale.html?rows=3000` is the scale test: thousands of memoized cells that all
432
- re-render avoidably, sharing one large context value and a typed-array prop. Its readout shows
433
- the library's own cost per commit and how many reports the per-commit cap skipped.
434
-
435
- `examples/vite-react` has three deliberate bugs. From the repo root:
238
+ ## API
436
239
 
437
- ```sh
438
- npm install
439
- npm --prefix examples/vite-react install
440
- npm run dev:example
240
+ ```ts
241
+ init(options?): () => void configure(options) disable() isEnabled()
242
+ track(component, name?) useWhyRerender(name, values, options?)
243
+ ensureDevtoolsHook() // create the global hook early (test setup files)
244
+ createCollector() // { reports, avoidable, notifier, clear, assertNoAvoidable, assertWithinBudget, fixes, summary }
245
+ createDevtoolsNotifier({ bufferSize?, target?, maxDepth?, flashAvoidable?, channel?, relay? })
246
+ rankFixes, formatFixes, rankRootCauses, formatRootCauses, analyzeCommit, rootCauseOf
247
+ summarizeReports, compareSummaries, formatComparison, parseExport
248
+ checkBudget, toBudget, assertWithinBudget
249
+ combineNotifiers, getRenderers, isProductionReact, serializeOptions, deserializeOptions, VERSION
250
+ // rerender-lens/vite rerenderLens(options)
251
+ // rerender-lens/setup side-effect entry
252
+ // rerender-lens/relay createRelayServer({ port?, host? })
253
+ // rerender-lens/vitest setupRerenderLens(options?, { afterAll }), default reporter; rerender-lens/vitest/setup
254
+ // rerender-lens/playwright installRerenderLens(page, options?), pullReports, clearReports, expectWithinBudget
441
255
  ```
442
256
 
443
- `http://localhost:5199/` runs the library through the Vite plugin (console, extension, and the
444
- built-in panel at `http://localhost:5199/__rerender-lens/`). `http://localhost:5199/plain.html`
445
- is the same app without the library, for trying the extension's *Inject the library* mode.
446
- `src/rerender-lens.ts` shows the manual setup for apps without the plugin.
257
+ The bridge at `window.__RERENDER_LENS_DEVTOOLS__` (`replay`, `clear`, `pull`, `info`, `configure`,
258
+ `highlight`, `flashAvoidable`, `inspect`) is what the extension and the panels talk to. Reports are
259
+ serialized with bounds (100 entries per container, depth 4, 20k nodes per report) and posted on
260
+ `window` only once a listener announced itself, so a page nobody inspects pays nothing per report.
261
+
262
+ ## How it works
263
+
264
+ - After each commit the fiber tree is walked from the root, skipping subtrees React bailed out
265
+ of. A component rendered when React set its `PerformedWork` flag; props, class state, hook
266
+ nodes and context reads are compared with the fiber's alternate.
267
+ - Work per commit is bounded: equality is memoized across the commit and gives up on values too
268
+ large to walk, at most 200 components are reported per commit (the rest is counted in
269
+ `info().truncated`), and a commit stops after 25 ms.
270
+ - A commit in which Fast Refresh swapped a component's code is skipped. Mounts are never reported;
271
+ StrictMode's double render is one commit and reports once.
272
+ - Development only: everything runs inside React's commit callback. Production builds are
273
+ detected and flagged (names may be minified).
274
+ - Fiber fields have been stable since React 16.9; the walk is wrapped so a change in React logs
275
+ one warning instead of breaking the app.
276
+
277
+ The suite runs on React 19, 18 and 17 in CI. What each version gives you:
278
+
279
+ | | React 19 | React 18 | React 17 |
280
+ | --- | --- | --- | --- |
281
+ | Props, state, parent attribution, avoidable verdicts, `memo` / `forwardRef` / classes | yes | yes | yes |
282
+ | Context changes (`useContext`, provider attribution) | yes | yes | no: React only records the value a component read from a context from 18 on, so a context change looks like a plain parent re-render (the library says so once) |
283
+ | `updaters` (who scheduled the commit) and effect-loop detection | yes | yes | no: React's updater tracking starts at 18 |
284
+ | `useSyncExternalStore`, `useTransition`, `useDeferredValue`, `useId` | yes | yes | not in React 17 |
285
+ | `use()`, `ref` as a prop, React Compiler output | yes | no | no |
286
+
287
+ ## Examples
288
+
289
+ `examples/vite-react` has three deliberate bugs and the built-in panel; `scale.html?rows=3000` is
290
+ the scale test with a readout of the library's own cost. `examples/next` is the same app in
291
+ Next.js, started from `instrumentation-client.ts` and reporting to the relay panel.
292
+
293
+ ```sh
294
+ npm install && npm --prefix examples/vite-react install
295
+ npm run dev:example # http://localhost:5199/ and /__rerender-lens/
296
+ ```
447
297
 
448
298
  ## Migrating from why-did-you-render
449
299
 
@@ -453,11 +303,10 @@ is the same app without the library, for trying the extension's *Inject the libr
453
303
  | `trackAllPureComponents` | `trackAllMemoized` |
454
304
  | `Comp.whyDidYouRender = true` | `track(Comp)` or `Comp.rerenderLens = true` |
455
305
  | `include` / `exclude` (RegExp[]) | same, plus strings and predicates |
456
- | `trackHooks` | same |
457
306
  | `logOnDifferentValues` | `logAll` |
458
- | `logOwnerReasons` | always on: `parent` and `owner` fields |
459
- | `notifier` (`{ Component, prevProps, ... }`) | `notifier` (`RenderReport`) |
460
- | `jsxImportSource: '@welldone-software/why-did-you-render'` | not needed |
307
+ | `logOwnerReasons` | always on: `parent` and `owner` |
308
+ | `notifier({ Component, prevProps, ... })` | `notifier(report: RenderReport)` |
309
+ | `jsxImportSource` | not needed |
461
310
 
462
311
  ## License
463
312