rerender-lens 0.4.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +192 -362
  3. package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
  4. package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
  5. package/dist/cli.cjs +248 -31
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +248 -31
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-CZebzpF6.d.ts → devtools-BYWat7nQ.d.ts} +7 -1
  10. package/dist/{devtools-BkCct3cJ.d.cts → devtools-DJi_6AfJ.d.cts} +7 -1
  11. package/dist/index.cjs +439 -41
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +131 -8
  14. package/dist/index.d.ts +131 -8
  15. package/dist/index.js +424 -42
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1673 -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 +1671 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1760 -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 +1751 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.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 +10 -2
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.js +10 -2
  40. package/dist/relay.js.map +1 -1
  41. package/dist/rerender-lens.iife.js +356 -54
  42. package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
  43. package/dist/runner-report-CW8X5RwK.d.ts +46 -0
  44. package/dist/setup.cjs +112 -27
  45. package/dist/setup.cjs.map +1 -1
  46. package/dist/setup.js +112 -27
  47. package/dist/setup.js.map +1 -1
  48. package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
  49. package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
  50. package/dist/vite.d.cts +1 -1
  51. package/dist/vite.d.ts +1 -1
  52. package/dist/vitest-setup.cjs +337 -41
  53. package/dist/vitest-setup.cjs.map +1 -1
  54. package/dist/vitest-setup.d.cts +4 -4
  55. package/dist/vitest-setup.d.ts +4 -4
  56. package/dist/vitest-setup.js +337 -41
  57. package/dist/vitest-setup.js.map +1 -1
  58. package/dist/vitest.cjs +400 -77
  59. package/dist/vitest.cjs.map +1 -1
  60. package/dist/vitest.d.cts +21 -39
  61. package/dist/vitest.d.ts +21 -39
  62. package/dist/vitest.js +401 -78
  63. package/dist/vitest.js.map +1 -1
  64. package/package.json +40 -4
  65. package/panel/panel.css +1 -0
  66. package/panel/panel.js +499 -309
package/README.md CHANGED
@@ -1,449 +1,280 @@
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). Also: an Elements-panel sidebar, a badge with the tab's avoidable count, hover to
34
+ highlight in the page, "open source" links, and the same panel next to the page (side panel) or
35
+ in its own window.
36
+
37
+ **Install** (until the Web Store listing is live): download `rerender-lens-chrome-<version>.zip`
38
+ from the [latest release](https://github.com/NexaLeaf/rerender-lens/releases), unzip it, open
39
+ `chrome://extensions`, turn on *Developer mode*, *Load unpacked*, pick the folder. Edge loads the
40
+ same folder; Firefox 128+ uses the `firefox` zip via `about:debugging`.
41
+
42
+ **Connect a page.** Local hosts (`localhost`, `127.0.0.1`, `*.localhost`, `*.local`) work out of
43
+ the box; for any other site click the toolbar icon and *Enable on this site*. Then either tick
44
+ *Inject the library* (the extension loads rerender-lens before React; no app code) or let the page
45
+ run the library itself:
60
46
 
61
47
  ```ts
62
48
  import { init, createDevtoolsNotifier } from 'rerender-lens';
63
49
  init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
64
50
  ```
65
51
 
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.
52
+ Injection tracks every `memo` / `PureComponent` and leaves the hook/context/state snapshots off
53
+ (*Include state* in Settings turns them on; they are the costliest part on large apps). Settings
54
+ are saved per origin. Details, shortcuts and packaging: [`extension/README.md`](extension/README.md).
72
55
 
73
- ## Install the package
74
-
75
- ```sh
76
- npm i -D rerender-lens
77
- ```
78
-
79
- Peer dependency: `react >= 16.8`. Tested with React 18 and 19.
80
-
81
- ## One-line setup per bundler
82
-
83
- **Vite** (recommended for Vite users: no extension injection, no permissions, one line):
56
+ ## Vite
84
57
 
85
58
  ```ts
86
59
  // vite.config.ts
87
60
  import { rerenderLens } from 'rerender-lens/vite';
88
-
89
- export default defineConfig({
90
- plugins: [react(), rerenderLens({ trackAllMemoized: true })],
91
- });
61
+ export default defineConfig({ plugins: [react(), rerenderLens({ trackAllMemoized: true, panel: true })] });
92
62
  ```
93
63
 
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.
64
+ Dev only (`vite build` is untouched). `panel: true` serves the panel at
65
+ `http://localhost:5173/__rerender-lens/`; the app and the panel talk over a same-origin
66
+ `BroadcastChannel`, so no extension is needed. `pages: ['/']` limits which HTML pages get the
67
+ setup script; `devtools: false` skips the bridge.
98
68
 
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.)
104
-
105
- **Next.js** (App or Pages router): run it before React from the client instrumentation file:
69
+ ## Next.js, Webpack, anything
106
70
 
107
71
  ```ts
108
- // instrumentation-client.ts
72
+ // Next.js: instrumentation-client.ts // Webpack / Rspack: entry: ['rerender-lens/setup', './src/index.tsx']
109
73
  import 'rerender-lens/setup';
110
74
  ```
111
75
 
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):
76
+ The setup entry starts the library with every `memo` / `PureComponent` tracked, outside
77
+ production builds. To see the panel without the extension:
124
78
 
125
79
  ```sh
126
- npx rerender-lens panel # http://127.0.0.1:4141/ serves the panel and relays messages
80
+ npx rerender-lens panel # http://127.0.0.1:4141/ (--port, --host 0.0.0.0 for another machine)
127
81
  ```
128
82
 
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
- ```
83
+ and point the app at it with `RERENDER_LENS_RELAY=http://127.0.0.1:4141`
84
+ (`NEXT_PUBLIC_RERENDER_LENS_RELAY` for Next.js), `createDevtoolsNotifier({ relay })`, or
85
+ `window.__RERENDER_LENS_RELAY__` set before the app loads. Settings, highlight and replay travel
86
+ back to the app; several apps or panels can share one relay.
136
87
 
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).
141
-
142
- ## Setup
88
+ ## Manual setup
143
89
 
144
90
  ```ts
145
- // src/rerender-lens.ts
146
91
  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
- ...
158
- ```
159
-
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.
163
-
164
- No `jsxImportSource`, no default-import requirement, no Babel plugin.
165
-
166
- ## Choosing what to track
167
-
168
- ```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
- });
92
+ if (import.meta.env.DEV) init({ trackAllMemoized: true, include: [/^Grid/, 'Sidebar'], exclude: ['DevOverlay'] });
248
93
  ```
249
94
 
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.
95
+ Import it before `react-dom` (React looks for the DevTools hook once, when it loads). Mark single
96
+ components with `track(Comp)` or `Comp.rerenderLens = true`; `configure()` changes options at
97
+ runtime. Inside a library or Storybook, `useWhyRerender('Row', { ...props, theme })` reports one
98
+ component without `init`.
252
99
 
253
- ### Vitest, whole suite
100
+ ## Tests and CI
254
101
 
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:
102
+ **Vitest**, whole suite, two config lines:
257
103
 
258
104
  ```ts
259
- // vitest.config.ts
260
105
  test: {
261
106
  setupFiles: ['rerender-lens/vitest/setup'],
262
- reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json', exportTo: 'rerender-lens.json' }]],
107
+ reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json' }]],
263
108
  }
264
109
  ```
265
110
 
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:
111
+ The reporter prints the run's ranked fixes and root causes, fails on budget violations, and can
112
+ write a panel-compatible export. Custom options: `setupRerenderLens(options, { afterAll })`.
271
113
 
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
114
+ **Jest**, the same two lines (`setupFilesAfterEnv`, not `setupFiles`: the hook needs `afterAll`):
115
+
116
+ ```js
117
+ // jest.config.js
118
+ module.exports = {
119
+ testEnvironment: 'jsdom',
120
+ setupFilesAfterEnv: ['rerender-lens/jest/setup'],
121
+ reporters: ['default', ['rerender-lens/jest', { budget: 'rerender-budget.json' }]],
122
+ };
277
123
  ```
278
124
 
279
- ### Playwright, real browser
125
+ Same options and same output as the Vitest reporter; a budget violation fails the run.
126
+
127
+ **Playwright**, real browser:
280
128
 
281
129
  ```ts
282
130
  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
- });
131
+ await installRerenderLens(page, { include: ['ProductRow'] }); // before page.goto
132
+ await page.goto('/products');
133
+ expectWithinBudget(await pullReports(page), { '*': 0 }); // throws with the ranked fixes
290
134
  ```
291
135
 
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
136
+ **Any test runner**, per test:
298
137
 
299
138
  ```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()
139
+ const collector = createCollector();
140
+ beforeAll(() => init({ trackAllMemoized: true, silent: true, notifier: collector.notifier }));
141
+ test('search does not re-render the rows', () => { /* ... */ collector.assertNoAvoidable(); });
305
142
  ```
306
143
 
307
- The `rerender-lens` CLI does the same with files from the extension (Export) or from `summary()`:
144
+ **CLI**, on a panel export or a session summary:
308
145
 
309
146
  ```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
147
+ npx rerender-lens fixes export.json # ranked fixes, then root causes
148
+ npx rerender-lens causes export.json # which component started each cascade
313
149
  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)
150
+ npx rerender-lens budget export.json rerender-budget.json # exit 1 when a component exceeds its budget
151
+ npx rerender-lens summary export.json --out before.json && npx rerender-lens compare before.json after.json
316
152
  ```
317
153
 
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';
154
+ ## What a report says
324
155
 
325
- function Row(props: RowProps) {
326
- const theme = useContext(ThemeContext);
327
- useWhyRerender('Row', { ...props, theme });
328
- ...
329
- }
330
- ```
156
+ Every re-render of a tracked component is a `RenderReport`. The fields you will read:
331
157
 
332
- Pass whatever values you want compared.
333
-
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 | |
158
+ | Field | Meaning |
351
159
  | --- | --- |
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`) |
160
+ | `component`, `path`, `owner` | display name, ancestry from the root, who created the element |
161
+ | `trigger` | `props`, `parent`, `state`, `hooks` or `mixed` |
162
+ | `avoidable` | `true` when nothing genuinely changed |
163
+ | `propChanges`, `stateChanges`, `hookChanges` | what changed: `path`, `kind`, `prev`, `next`; context entries name the provider and the changed keys |
164
+ | `hookState`, `contexts`, `state` | every hook, context and class state value (`includeState`) |
165
+ | `parent`, `updaters` | the ancestor that rendered in the same commit and why; the components that scheduled the commit |
166
+ | `commitId`, `commitPriority`, `commitCause` | one id per React commit; discrete input / transition / idle; `effect-after-commit` or `suspense-resolved` |
167
+ | `selfDuration`, `treeDuration`, `source` | render time (dev/profiling builds); file, line and column of the element |
168
+ | `reasons` | the explanations with the fix, as printed |
358
169
 
359
- The DevTools extension consumes this (see [Chrome extension](#chrome-extension) above); with
360
- injection enabled it needs no `init` call at all.
170
+ | `kind` | Means | Fix |
171
+ | --- | --- | --- |
172
+ | `deep-equal` | new reference, same contents | `useMemo`, or hoist a constant |
173
+ | `function` | new function, same body | `useCallback` |
174
+ | `element` | new element, same type and props | `useMemo` the element or pass it as `children` |
175
+ | `different` | a real change | none |
361
176
 
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).
177
+ A `parent` trigger with no changes means identical props and an ancestor re-rendered: wrap the
178
+ component in `React.memo`.
364
179
 
365
- ## API
180
+ ## What counts as avoidable
366
181
 
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
- ```
182
+ `avoidable` is true when the render produced no genuine change in props, state or hooks *and*
183
+ nothing about the situation explains the render away. The rules, on the shapes modern apps produce
184
+ (each row is a test in `test/modern.test.ts` and a card on the example's `/modern.html`):
391
185
 
392
- `Options`:
186
+ | Situation | Verdict | Why / fix |
187
+ | --- | --- | --- |
188
+ | Parent re-rendered, identical props, plain function or class component | avoidable, `parent` | wrap in `React.memo`; classes: extend `PureComponent` or implement `shouldComponentUpdate` |
189
+ | 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 |
190
+ | `memo` / `PureComponent` / `shouldComponentUpdate → false` with equal props | no report | React bailed out; the component never rendered |
191
+ | Inline object, array or element prop (`deep-equal`, `element`) | avoidable | `useMemo`, hoist constants, pass static elements as `children` from a stable parent |
192
+ | 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 |
193
+ | `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` |
194
+ | `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 |
195
+ | `useState`/`setState` with a value deep-equal to the current one | avoidable | reuse the existing object or bail out before calling the setter |
196
+ | A genuine prop, state, context or store change | not avoidable (`props` / `state` / `hooks` / `mixed`) | the change is listed with its path |
197
+ | `startTransition(() => setState(…))` | parent: `state` at `commitPriority: 'normal'`, not avoidable; memo children with equal props: no report | nothing to fix |
198
+ | 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 |
199
+ | 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 |
200
+ | Mount, StrictMode's double render, a Fast Refresh commit | no report | never reported (StrictMode is one commit) |
201
+
202
+ ## Options
393
203
 
394
204
  | Option | Default | |
395
205
  | --- | --- | --- |
396
206
  | `trackAllMemoized` | `false` | track every `memo` / `PureComponent` |
397
207
  | `trackAllComponents` | `false` | track everything (noisy) |
398
- | `include` / `exclude` | | display-name matchers |
208
+ | `include` / `exclude` | | display-name matchers: string, RegExp or predicate |
399
209
  | `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 |
210
+ | `includeState` | `true` (`false` when injected) | put every hook, context and class state value on each report; the costliest option on large trees |
211
+ | `resolveHookNames` | `false` | label hooks with the custom hooks that own them (`useCart › useState#0`) |
402
212
  | `logAll` | `false` | print non-avoidable reports too |
403
- | `silent` | `false` | never print; notifier still runs |
213
+ | `silent` | `false` | never print; the notifier still runs |
404
214
  | `notifier` | | receives every `RenderReport` |
405
- | `collapse` | `true` | `console.groupCollapsed` vs `console.group` |
406
- | `console` | `console` | sink for printing |
407
215
  | `ignoreHotReload` | `true` | skip commits caused by Fast Refresh |
408
216
  | `maxReportsPerComponent` | `0` | stop printing a component after N reports |
217
+ | `collapse`, `console` | `true`, `console` | console group style and sink |
409
218
 
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:
219
+ ## API
436
220
 
437
- ```sh
438
- npm install
439
- npm --prefix examples/vite-react install
440
- npm run dev:example
221
+ ```ts
222
+ init(options?): () => void configure(options) disable() isEnabled()
223
+ track(component, name?) useWhyRerender(name, values, options?)
224
+ ensureDevtoolsHook() // create the global hook early (test setup files)
225
+ createCollector() // { reports, avoidable, notifier, clear, assertNoAvoidable, assertWithinBudget, fixes, summary }
226
+ createDevtoolsNotifier({ bufferSize?, target?, maxDepth?, flashAvoidable?, channel?, relay? })
227
+ rankFixes, formatFixes, rankRootCauses, formatRootCauses, analyzeCommit, rootCauseOf
228
+ summarizeReports, compareSummaries, formatComparison, parseExport
229
+ checkBudget, toBudget, assertWithinBudget
230
+ combineNotifiers, getRenderers, isProductionReact, serializeOptions, deserializeOptions, VERSION
231
+ // rerender-lens/vite rerenderLens(options)
232
+ // rerender-lens/setup side-effect entry
233
+ // rerender-lens/relay createRelayServer({ port?, host? })
234
+ // rerender-lens/vitest setupRerenderLens(options?, { afterAll }), default reporter; rerender-lens/vitest/setup
235
+ // rerender-lens/playwright installRerenderLens(page, options?), pullReports, clearReports, expectWithinBudget
441
236
  ```
442
237
 
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.
238
+ The bridge at `window.__RERENDER_LENS_DEVTOOLS__` (`replay`, `clear`, `pull`, `info`, `configure`,
239
+ `highlight`, `flashAvoidable`, `inspect`) is what the extension and the panels talk to. Reports are
240
+ serialized with bounds (100 entries per container, depth 4, 20k nodes per report) and posted on
241
+ `window` only once a listener announced itself, so a page nobody inspects pays nothing per report.
242
+
243
+ ## How it works
244
+
245
+ - After each commit the fiber tree is walked from the root, skipping subtrees React bailed out
246
+ of. A component rendered when React set its `PerformedWork` flag; props, class state, hook
247
+ nodes and context reads are compared with the fiber's alternate.
248
+ - Work per commit is bounded: equality is memoized across the commit and gives up on values too
249
+ large to walk, at most 200 components are reported per commit (the rest is counted in
250
+ `info().truncated`), and a commit stops after 25 ms.
251
+ - A commit in which Fast Refresh swapped a component's code is skipped. Mounts are never reported;
252
+ StrictMode's double render is one commit and reports once.
253
+ - Development only: everything runs inside React's commit callback. Production builds are
254
+ detected and flagged (names may be minified).
255
+ - Fiber fields have been stable since React 16.9; the walk is wrapped so a change in React logs
256
+ one warning instead of breaking the app.
257
+
258
+ The suite runs on React 19, 18 and 17 in CI. What each version gives you:
259
+
260
+ | | React 19 | React 18 | React 17 |
261
+ | --- | --- | --- | --- |
262
+ | Props, state, parent attribution, avoidable verdicts, `memo` / `forwardRef` / classes | yes | yes | yes |
263
+ | 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) |
264
+ | `updaters` (who scheduled the commit) and effect-loop detection | yes | yes | no: React's updater tracking starts at 18 |
265
+ | `useSyncExternalStore`, `useTransition`, `useDeferredValue`, `useId` | yes | yes | not in React 17 |
266
+ | `use()`, `ref` as a prop, React Compiler output | yes | no | no |
267
+
268
+ ## Examples
269
+
270
+ `examples/vite-react` has three deliberate bugs and the built-in panel; `scale.html?rows=3000` is
271
+ the scale test with a readout of the library's own cost. `examples/next` is the same app in
272
+ Next.js, started from `instrumentation-client.ts` and reporting to the relay panel.
273
+
274
+ ```sh
275
+ npm install && npm --prefix examples/vite-react install
276
+ npm run dev:example # http://localhost:5199/ and /__rerender-lens/
277
+ ```
447
278
 
448
279
  ## Migrating from why-did-you-render
449
280
 
@@ -453,11 +284,10 @@ is the same app without the library, for trying the extension's *Inject the libr
453
284
  | `trackAllPureComponents` | `trackAllMemoized` |
454
285
  | `Comp.whyDidYouRender = true` | `track(Comp)` or `Comp.rerenderLens = true` |
455
286
  | `include` / `exclude` (RegExp[]) | same, plus strings and predicates |
456
- | `trackHooks` | same |
457
287
  | `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 |
288
+ | `logOwnerReasons` | always on: `parent` and `owner` |
289
+ | `notifier({ Component, prevProps, ... })` | `notifier(report: RenderReport)` |
290
+ | `jsxImportSource` | not needed |
461
291
 
462
292
  ## License
463
293