rerender-lens 0.3.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 +118 -0
  2. package/README.md +192 -353
  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-BHGUUo-p.d.ts → devtools-BYWat7nQ.d.ts} +17 -3
  10. package/dist/{devtools-DmhWiWcN.d.cts → devtools-DJi_6AfJ.d.cts} +17 -3
  11. package/dist/index.cjs +635 -109
  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 +620 -110
  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 +235 -30
  32. package/dist/playwright.cjs.map +1 -1
  33. package/dist/playwright.d.cts +8 -6
  34. package/dist/playwright.d.ts +8 -6
  35. package/dist/playwright.js +235 -31
  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 +1100 -337
  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 +308 -95
  45. package/dist/setup.cjs.map +1 -1
  46. package/dist/setup.js +308 -95
  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 +452 -87
  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 +452 -87
  57. package/dist/vitest-setup.js.map +1 -1
  58. package/dist/vitest.cjs +515 -123
  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 +516 -124
  63. package/dist/vitest.js.map +1 -1
  64. package/package.json +41 -5
  65. package/panel/panel.css +1 -34
  66. package/panel/panel.js +865 -713
package/README.md CHANGED
@@ -1,440 +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. Change what is tracked from
56
- Settings; the choice is saved per origin.
57
- - **The page runs the library**: install the package and pass the DevTools notifier (see below).
58
- 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:
59
46
 
60
47
  ```ts
61
48
  import { init, createDevtoolsNotifier } from 'rerender-lens';
62
49
  init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
63
50
  ```
64
51
 
65
- Production React builds are detected and flagged (names may be minified, hooks unlabeled). If
66
- React DevTools is also installed and its Components tab comes up empty with injection on, tick
67
- *Let React DevTools create the hook* for that origin.
68
-
69
- `extension/README.md` has the details: keyboard shortcuts, how the transport works, Firefox and
70
- 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).
71
55
 
72
- ## Install the package
73
-
74
- ```sh
75
- npm i -D rerender-lens
76
- ```
77
-
78
- Peer dependency: `react >= 16.8`. Tested with React 18 and 19.
79
-
80
- ## One-line setup per bundler
81
-
82
- **Vite** (recommended for Vite users: no extension injection, no permissions, one line):
56
+ ## Vite
83
57
 
84
58
  ```ts
85
59
  // vite.config.ts
86
60
  import { rerenderLens } from 'rerender-lens/vite';
87
-
88
- export default defineConfig({
89
- plugins: [react(), rerenderLens({ trackAllMemoized: true })],
90
- });
61
+ export default defineConfig({ plugins: [react(), rerenderLens({ trackAllMemoized: true, panel: true })] });
91
62
  ```
92
63
 
93
- The plugin injects a module before your entry in dev (`vite build` is untouched) that calls
94
- `init` with your options plus the DevTools notifier. Matchers can be strings or RegExps;
95
- `devtools: false` skips the bridge, `applyInBuild: true` keeps it in builds, `pages: ['/']`
96
- 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.
97
68
 
98
- **No extension at all**: add `panel: true` and open `http://localhost:5173/__rerender-lens/` in a
99
- second tab. The dev server serves the same panel the extension uses; the app publishes reports on
100
- a same-origin `BroadcastChannel` and the panel sends settings and highlight commands back over it.
101
- Panel state lives in `localStorage`. (`channel` renames the channel, `panel: '/some/path/'` moves
102
- the mount.)
103
-
104
- **Next.js** (App or Pages router): run it before React from the client instrumentation file:
69
+ ## Next.js, Webpack, anything
105
70
 
106
71
  ```ts
107
- // instrumentation-client.ts
72
+ // Next.js: instrumentation-client.ts // Webpack / Rspack: entry: ['rerender-lens/setup', './src/index.tsx']
108
73
  import 'rerender-lens/setup';
109
74
  ```
110
75
 
111
- **Webpack / Rspack / others**: put the setup entry first:
112
-
113
- ```js
114
- entry: ['rerender-lens/setup', './src/index.tsx'],
115
- ```
116
-
117
- `rerender-lens/setup` is a side-effect module that calls
118
- `init({ trackAllMemoized: true, notifier: createDevtoolsNotifier() })` unless
119
- `process.env.NODE_ENV === 'production'` or the page already runs the library. Use `configure()`
120
- afterwards to change options, or the extension's Settings.
121
-
122
- **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:
123
78
 
124
79
  ```sh
125
- 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)
126
81
  ```
127
82
 
128
- Point the app at it and open the printed URL in any browser:
129
-
130
- ```ts
131
- init({ trackAllMemoized: true, notifier: createDevtoolsNotifier({ relay: 'http://127.0.0.1:4141' }) });
132
- // or, with rerender-lens/setup: RERENDER_LENS_RELAY=http://127.0.0.1:4141 (NEXT_PUBLIC_RERENDER_LENS_RELAY for Next.js)
133
- // or, before the app loads: window.__RERENDER_LENS_RELAY__ = 'http://127.0.0.1:4141'
134
- ```
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.
135
87
 
136
- The app streams commands in over server-sent events and posts hello, reports and replies back; the
137
- panel connects the same way (`panel.html?relay=…`), so Settings, highlight and replay all work.
138
- Several apps or panels can share one relay; the panel re-attaches when an app reloads.
139
- `--port` and `--host` change where it listens (`--host 0.0.0.0` for another machine).
140
-
141
- ## Setup
88
+ ## Manual setup
142
89
 
143
90
  ```ts
144
- // src/rerender-lens.ts
145
91
  import { init } from 'rerender-lens';
146
-
147
- if (import.meta.env.DEV) {
148
- init({ trackAllMemoized: true });
149
- }
150
- ```
151
-
152
- ```ts
153
- // src/main.tsx
154
- import './rerender-lens';
155
- import { createRoot } from 'react-dom/client';
156
- ...
157
- ```
158
-
159
- Import the setup file before `react-dom`. React DOM looks for the DevTools hook once, when its
160
- module loads; `init` creates the hook if nothing else did. If the React DevTools extension is
161
- installed, or Vite's Fast Refresh preamble runs, the hook already exists and order does not matter.
162
-
163
- No `jsxImportSource`, no default-import requirement, no Babel plugin.
164
-
165
- ## Choosing what to track
166
-
167
- ```ts
168
- init({
169
- trackAllMemoized: true, // every React.memo and PureComponent
170
- include: [/^Grid/, 'Sidebar'], // by display name: string, RegExp or predicate
171
- exclude: ['DevOverlay'],
172
- });
173
- ```
174
-
175
- Or mark a component:
176
-
177
- ```ts
178
- import { track } from 'rerender-lens';
179
-
180
- export const ProductRow = track(function ProductRow(props: Props) { ... });
181
- export default track(memo(Sidebar));
182
- export const Cell = track((props: CellProps) => ..., 'Cell'); // name for anonymous arrows
183
- ```
184
-
185
- Marking sets the static `rerenderLens = true`; you can also set it by hand. `configure()`
186
- changes any option at runtime without remounting anything.
187
-
188
- ## What a report contains
189
-
190
- Every update of a tracked component produces a `RenderReport`:
191
-
192
- | Field | Meaning |
193
- | --- | --- |
194
- | `component` | display name |
195
- | `trigger` | `props`, `parent`, `state`, `hooks` or `mixed` |
196
- | `avoidable` | `true` when nothing genuinely changed |
197
- | `propChanges` | one entry per changed prop with `path`, `kind`, `prev`, `next` |
198
- | `stateChanges` | class components: `this.state` diff |
199
- | `hookChanges` | `useState`, `useReducer`, `useSyncExternalStore` and `useContext` values that changed; context entries carry `provider` (who renders it) and `changedKeys` / `totalKeys` for object values |
200
- | `hookState`, `contexts`, `state` | current value of every state hook, every context read, and class `this.state` (off with `includeState: false`) |
201
- | `updaters` | components that scheduled the commit (`setState`, dispatch), from React's updater tracking |
202
- | `commitCause`, `afterCommit` | `effect-after-commit` (an effect of the previous commit set state) or `suspense-resolved` |
203
- | `key` | the element's `key`, when it has one |
204
- | `parent` | nearest ancestor that rendered in the same commit, and why |
205
- | `owner` | component that created the element (dev builds) |
206
- | `path` | component ancestry from the root |
207
- | `instanceId`, `renderCount` | stable per mounted instance |
208
- | `memoized` | `React.memo` / `PureComponent`: props alone decide whether it re-renders |
209
- | `selfDuration`, `treeDuration` | own render time, and with everything below that rendered (dev/profiling builds) |
210
- | `commitId` | shared by every report of one React commit |
211
- | `commitPriority` | `immediate` (clicks, keys), `user-blocking` (continuous input), `normal` (transitions, async), `low`, `idle` |
212
- | `source` | file, line and column where the element was created (dev builds) |
213
- | `reasons` | human-readable explanations with the fix |
214
-
215
- Change kinds:
216
-
217
- | `kind` | Means | Fix |
218
- | --- | --- | --- |
219
- | `deep-equal` | new reference, same contents | `useMemo`, or hoist a constant |
220
- | `function` | new function, same body | `useCallback` |
221
- | `element` | new element, same type and props | `useMemo` the element or pass it as `children` |
222
- | `different` | a real change | none needed |
223
- | `added` / `removed` | key appeared or disappeared | usually a real change |
224
-
225
- A `parent` trigger with no changes at all means: identical props, an ancestor re-rendered.
226
- Wrap the component in `React.memo`.
227
-
228
- By default only avoidable re-renders are printed; pass `logAll: true` to print every report.
229
- The `notifier` always receives every report.
230
-
231
- ## Use in tests
232
-
233
- ```ts
234
- import { init, disable, createCollector } from 'rerender-lens';
235
-
236
- const collector = createCollector();
237
- beforeAll(() => init({ trackAllMemoized: true, silent: true, notifier: collector.notifier }));
238
- afterAll(disable);
239
- beforeEach(collector.clear);
240
-
241
- test('typing in the search box does not re-render the grid rows', async () => {
242
- render(<ProductPage />);
243
- await user.type(screen.getByRole('searchbox'), 'abc');
244
- collector.assertNoAvoidable(); // throws listing every component and reason
245
- // or: expect(collector.avoidable).toHaveLength(0)
246
- });
92
+ if (import.meta.env.DEV) init({ trackAllMemoized: true, include: [/^Grid/, 'Sidebar'], exclude: ['DevOverlay'] });
247
93
  ```
248
94
 
249
- In Vitest or Jest, call `ensureDevtoolsHook()` (or `init`) from a `setupFiles` entry so the hook
250
- 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`.
251
99
 
252
- ### Vitest, whole suite
100
+ ## Tests and CI
253
101
 
254
- Two config lines collect every avoidable re-render across the run and print the ranked fixes at
255
- the end; a budget file turns it into a gate:
102
+ **Vitest**, whole suite, two config lines:
256
103
 
257
104
  ```ts
258
- // vitest.config.ts
259
105
  test: {
260
106
  setupFiles: ['rerender-lens/vitest/setup'],
261
- reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json', exportTo: 'rerender-lens.json' }]],
107
+ reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json' }]],
262
108
  }
263
109
  ```
264
110
 
265
- The setup entry starts the library (all memoized components, silent) in every test file and hands
266
- the file's reports to the reporter; the reporter prints `rerender-lens: N reports, M avoidable`
267
- with the fixes, fails the run on budget violations, and can write a panel-compatible export
268
- (`Sessions > Import` in the extension). For other options, or per-test assertions, write your own
269
- 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 })`.
270
113
 
271
- ```ts
272
- import { afterAll } from 'vitest';
273
- import { setupRerenderLens } from 'rerender-lens/vitest';
274
- export const lens = setupRerenderLens({ include: [/^Grid/], failFast: true }, { afterAll });
275
- // 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
+ };
276
123
  ```
277
124
 
278
- ### Playwright, real browser
125
+ Same options and same output as the Vitest reporter; a budget violation fails the run.
126
+
127
+ **Playwright**, real browser:
279
128
 
280
129
  ```ts
281
130
  import { installRerenderLens, pullReports, expectWithinBudget } from 'rerender-lens/playwright';
282
-
283
- test('search does not re-render the grid', async ({ page }) => {
284
- await installRerenderLens(page, { include: ['ProductRow'] }); // before goto: runs before React
285
- await page.goto('/products');
286
- await page.getByRole('searchbox').fill('abc');
287
- expectWithinBudget(await pullReports(page), { '*': 0 }); // throws with the ranked fixes
288
- });
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
289
134
  ```
290
135
 
291
- `installRerenderLens` injects the same bundle the extension uses at document start, so it works on
292
- any build the browser can load (production builds are flagged; names may be minified). Pass
293
- `relay: 'http://127.0.0.1:4141'` to watch the run in the panel, `clearReports(page)` between
294
- scenarios.
295
-
296
- ### Budgets and comparisons in CI
136
+ **Any test runner**, per test:
297
137
 
298
138
  ```ts
299
- // allow known offenders, fail on anything new or worse
300
- collector.assertWithinBudget(JSON.parse(readFileSync('rerender-budget.json', 'utf8')));
301
- // produce the baseline once: writeFileSync('rerender-budget.json', JSON.stringify(toBudget(collector.reports)))
302
- collector.fixes(); // ranked fixes, most re-renders removed first
303
- 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(); });
304
142
  ```
305
143
 
306
- 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:
307
145
 
308
146
  ```sh
309
- npx rerender-lens fixes export.json # ranked fixes
310
- npx rerender-lens summary export.json --out before.json
311
- 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
312
149
  npx rerender-lens budget export.json --init > rerender-budget.json
313
- npx rerender-lens budget export.json rerender-budget.json # exit 1 when a component exceeds its budget
314
- 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
315
152
  ```
316
153
 
317
- ## Track a single component from the inside
318
-
319
- Works anywhere without `init`, for example inside library packages or Storybook:
320
-
321
- ```ts
322
- import { useWhyRerender } from 'rerender-lens';
154
+ ## What a report says
323
155
 
324
- function Row(props: RowProps) {
325
- const theme = useContext(ThemeContext);
326
- useWhyRerender('Row', { ...props, theme });
327
- ...
328
- }
329
- ```
156
+ Every re-render of a tracked component is a `RenderReport`. The fields you will read:
330
157
 
331
- Pass whatever values you want compared.
332
-
333
- ## DevTools bridge
334
-
335
- ```ts
336
- import { createDevtoolsNotifier } from 'rerender-lens';
337
-
338
- init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
339
- ```
340
-
341
- Each report is serialized (functions become `ƒ name`, elements `<Type>`, cycles cut) and posted
342
- on `window` as `{ __rerenderLens: true, version: 2, type: 'report', payload }`. The last 300
343
- reports are buffered. The bridge at `window.__RERENDER_LENS_DEVTOOLS__` exposes:
344
-
345
- | Method | |
158
+ | Field | Meaning |
346
159
  | --- | --- |
347
- | `replay()` / `clear()` | re-post or drop the buffer |
348
- | `pull(since)` | reports newer than a sequence number, for panels that poll instead of listening |
349
- | `info()` | library version, protocol, React renderers (version, dev/prod), current options |
350
- | `configure(options)` / `getOptions()` | change options at runtime; matchers as strings (`"/^Grid/"`) |
351
- | `highlight(instanceId)` / `flashAvoidable(on)` | outline a component's DOM in the page, or flash avoidable renders |
352
- | `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 |
353
169
 
354
- The DevTools extension consumes this (see [Chrome extension](#chrome-extension) above); with
355
- 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 |
356
176
 
357
- Every report also carries `commitId` (shared by all reports of one React commit) and, in dev
358
- 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`.
359
179
 
360
- ## API
180
+ ## What counts as avoidable
361
181
 
362
- ```ts
363
- init(options?): () => void // returns disable
364
- configure(options): void // merge options at runtime
365
- disable(): void
366
- isEnabled(): boolean
367
- track(component, name?): component
368
- ensureDevtoolsHook(): hook // create the global hook early (test setup files)
369
- // 'rerender-lens/vite': rerenderLens(options), renderSetupModule(options)
370
- // 'rerender-lens/setup': side-effect entry (init with defaults outside production)
371
- // 'rerender-lens/relay': createRelayServer({ port?, host? }) — what `rerender-lens panel` runs
372
- // 'rerender-lens/playwright': installRerenderLens(page, options?), pullReports(page), clearReports(page), expectWithinBudget(reports, budget)
373
- // 'rerender-lens/vitest': setupRerenderLens(options?, { afterAll }), default reporter ({ budget?, exportTo?, limit? }); 'rerender-lens/vitest/setup'
374
- useWhyRerender(name, values, options?)
375
- createCollector(): { reports, avoidable, notifier, clear, assertNoAvoidable, fixes, summary, assertWithinBudget }
376
- rankFixes(reports), formatFixes(reports) // ranked fixes
377
- summarizeReports(reports), compareSummaries(a, b), formatComparison(c), parseExport(json)
378
- checkBudget(reports, budget), toBudget(reports), assertWithinBudget(reports, budget)
379
- combineNotifiers(...notifiers): Notifier
380
- createDevtoolsNotifier({ bufferSize?, target?, maxDepth?, flashAvoidable?, channel?, relay? }): Notifier
381
- getRenderers(), isProductionReact() // what react-dom registered on the DevTools hook
382
- serializeOptions(o), deserializeOptions(o) // Options <-> JSON-safe form used by the bridge
383
- VERSION
384
- deepEqual(a, b), diffRecords(prev, next), classify(prev, next)
385
- ```
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`):
386
185
 
387
- `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
388
203
 
389
204
  | Option | Default | |
390
205
  | --- | --- | --- |
391
206
  | `trackAllMemoized` | `false` | track every `memo` / `PureComponent` |
392
207
  | `trackAllComponents` | `false` | track everything (noisy) |
393
- | `include` / `exclude` | | display-name matchers |
208
+ | `include` / `exclude` | | display-name matchers: string, RegExp or predicate |
394
209
  | `trackHooks` | `true` | diff hook state and contexts |
395
- | `includeState` | `true` | put every hook, context and class state value on each report |
396
- | `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`) |
397
212
  | `logAll` | `false` | print non-avoidable reports too |
398
- | `silent` | `false` | never print; notifier still runs |
213
+ | `silent` | `false` | never print; the notifier still runs |
399
214
  | `notifier` | | receives every `RenderReport` |
400
- | `collapse` | `true` | `console.groupCollapsed` vs `console.group` |
401
- | `console` | `console` | sink for printing |
402
215
  | `ignoreHotReload` | `true` | skip commits caused by Fast Refresh |
403
216
  | `maxReportsPerComponent` | `0` | stop printing a component after N reports |
217
+ | `collapse`, `console` | `true`, `console` | console group style and sink |
404
218
 
405
- ## How it works, and the caveats that follow
406
-
407
- - After each commit, the fiber tree is walked from the root, skipping subtrees React bailed out
408
- of. A component counts as re-rendered when React set its `PerformedWork` flag. Props, class
409
- state, hook state nodes and context dependencies are compared against the fiber's alternate.
410
- - Hook labels come from React's dev-only hook type list when it maps one-to-one onto the state
411
- nodes; otherwise nodes are labelled by inspection (`useState`, `useReducer`,
412
- `useSyncExternalStore`, or `state` for internal nodes of `useTransition` and friends).
413
- - A commit in which Fast Refresh swapped a component's code is skipped entirely
414
- (`ignoreHotReload`). The edited component's children re-render with identical props during
415
- that commit, which would otherwise look like avoidable re-renders.
416
- - The mount render is never reported. StrictMode's double render happens inside one commit and
417
- is reported once.
418
- - Everything runs during React's commit callback, synchronously. It is meant for development;
419
- keep it out of production builds.
420
- - Fiber field names have been stable since React 16.9 (`flags` was `effectTag` before 17; both
421
- are handled). Future React versions may change internals; the walk is wrapped so a failure
422
- logs one warning instead of breaking the app.
423
-
424
- ## Example app
425
-
426
- `examples/vite-react` has three deliberate bugs. From the repo root:
219
+ ## API
427
220
 
428
- ```sh
429
- npm install
430
- npm --prefix examples/vite-react install
431
- 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
432
236
  ```
433
237
 
434
- `http://localhost:5199/` runs the library through the Vite plugin (console, extension, and the
435
- built-in panel at `http://localhost:5199/__rerender-lens/`). `http://localhost:5199/plain.html`
436
- is the same app without the library, for trying the extension's *Inject the library* mode.
437
- `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
+ ```
438
278
 
439
279
  ## Migrating from why-did-you-render
440
280
 
@@ -444,11 +284,10 @@ is the same app without the library, for trying the extension's *Inject the libr
444
284
  | `trackAllPureComponents` | `trackAllMemoized` |
445
285
  | `Comp.whyDidYouRender = true` | `track(Comp)` or `Comp.rerenderLens = true` |
446
286
  | `include` / `exclude` (RegExp[]) | same, plus strings and predicates |
447
- | `trackHooks` | same |
448
287
  | `logOnDifferentValues` | `logAll` |
449
- | `logOwnerReasons` | always on: `parent` and `owner` fields |
450
- | `notifier` (`{ Component, prevProps, ... }`) | `notifier` (`RenderReport`) |
451
- | `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 |
452
291
 
453
292
  ## License
454
293