rerender-lens 0.1.0 → 0.3.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 (79) hide show
  1. package/CHANGELOG.md +164 -6
  2. package/README.md +298 -75
  3. package/dist/budget-COBu7jBU.d.cts +64 -0
  4. package/dist/budget-LkNjGtRc.d.ts +64 -0
  5. package/dist/cli.cjs +458 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +4 -0
  8. package/dist/cli.d.ts +4 -0
  9. package/dist/cli.js +452 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/devtools-BHGUUo-p.d.ts +244 -0
  12. package/dist/devtools-DmhWiWcN.d.cts +244 -0
  13. package/dist/index.cjs +1686 -74
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +67 -153
  16. package/dist/index.d.ts +67 -153
  17. package/dist/index.js +1643 -13
  18. package/dist/index.js.map +1 -1
  19. package/dist/notifiers-BGQtWKfX.d.cts +90 -0
  20. package/dist/notifiers-BjjGSHpp.d.ts +90 -0
  21. package/dist/playwright.cjs +196 -0
  22. package/dist/playwright.cjs.map +1 -0
  23. package/dist/playwright.d.cts +35 -0
  24. package/dist/playwright.d.ts +35 -0
  25. package/dist/playwright.js +184 -0
  26. package/dist/playwright.js.map +1 -0
  27. package/dist/relay.cjs +166 -0
  28. package/dist/relay.cjs.map +1 -0
  29. package/dist/relay.d.cts +41 -0
  30. package/dist/relay.d.ts +41 -0
  31. package/dist/relay.js +161 -0
  32. package/dist/relay.js.map +1 -0
  33. package/dist/rerender-lens.iife.js +1717 -0
  34. package/dist/setup.cjs +1510 -0
  35. package/dist/setup.cjs.map +1 -0
  36. package/dist/setup.d.cts +2 -0
  37. package/dist/setup.d.ts +2 -0
  38. package/dist/setup.js +1508 -0
  39. package/dist/setup.js.map +1 -0
  40. package/dist/types-BzUEVkxJ.d.cts +177 -0
  41. package/dist/types-BzUEVkxJ.d.ts +177 -0
  42. package/dist/vite.cjs +113 -0
  43. package/dist/vite.cjs.map +1 -0
  44. package/dist/vite.d.cts +84 -0
  45. package/dist/vite.d.ts +84 -0
  46. package/dist/vite.js +103 -0
  47. package/dist/vite.js.map +1 -0
  48. package/dist/vitest-setup.cjs +1299 -0
  49. package/dist/vitest-setup.cjs.map +1 -0
  50. package/dist/vitest-setup.d.cts +8 -0
  51. package/dist/vitest-setup.d.ts +8 -0
  52. package/dist/vitest-setup.js +1297 -0
  53. package/dist/vitest-setup.js.map +1 -0
  54. package/dist/vitest.cjs +1360 -0
  55. package/dist/vitest.cjs.map +1 -0
  56. package/dist/vitest.d.cts +52 -0
  57. package/dist/vitest.d.ts +52 -0
  58. package/dist/vitest.js +1351 -0
  59. package/dist/vitest.js.map +1 -0
  60. package/package.json +83 -17
  61. package/panel/panel.css +418 -0
  62. package/panel/panel.html +12 -0
  63. package/panel/panel.js +3325 -0
  64. package/dist/chunk-3YHI7BCU.js +0 -544
  65. package/dist/chunk-3YHI7BCU.js.map +0 -1
  66. package/dist/chunk-DOYB4MKH.cjs +0 -562
  67. package/dist/chunk-DOYB4MKH.cjs.map +0 -1
  68. package/dist/jsx-dev-runtime.cjs +0 -32
  69. package/dist/jsx-dev-runtime.cjs.map +0 -1
  70. package/dist/jsx-dev-runtime.d.cts +0 -8
  71. package/dist/jsx-dev-runtime.d.ts +0 -8
  72. package/dist/jsx-dev-runtime.js +0 -9
  73. package/dist/jsx-dev-runtime.js.map +0 -1
  74. package/dist/jsx-runtime.cjs +0 -34
  75. package/dist/jsx-runtime.cjs.map +0 -1
  76. package/dist/jsx-runtime.d.cts +0 -9
  77. package/dist/jsx-runtime.d.ts +0 -9
  78. package/dist/jsx-runtime.js +0 -10
  79. package/dist/jsx-runtime.js.map +0 -1
package/README.md CHANGED
@@ -1,16 +1,75 @@
1
1
  # rerender-lens
2
2
 
3
- Find avoidable React re-renders and learn exactly which prop, state or hook caused them.
4
- A modern alternative to `why-did-you-render`: TypeScript, structured reports you can assert on
5
- in tests, a hook for tracking a single component, and a bridge for a DevTools panel.
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.
6
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.
9
+
10
+ ```
11
+ ▸ [rerender-lens] <ProductRow> avoidable re-render: 1 equal by value, 1 new function
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).
15
+ at App > ProductPage > ProductRow
7
16
  ```
8
- [rerender-lens] <ProductRow> avoidable re-render: 1 equal by value, 1 new function
9
- - 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.
10
- - prop "onSelect" is a new function instance on every render: wrap it in useCallback (or hoist it out of the parent's render).
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).
21
+
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.
59
+
60
+ ```ts
61
+ import { init, createDevtoolsNotifier } from 'rerender-lens';
62
+ init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
11
63
  ```
12
64
 
13
- ## Install
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.
71
+
72
+ ## Install the package
14
73
 
15
74
  ```sh
16
75
  npm i -D rerender-lens
@@ -18,18 +77,75 @@ npm i -D rerender-lens
18
77
 
19
78
  Peer dependency: `react >= 16.8`. Tested with React 18 and 19.
20
79
 
21
- ## Setup
80
+ ## One-line setup per bundler
22
81
 
23
- Create a file that runs **before anything else imports React components**, and import it first
24
- in your entry point. Development only.
82
+ **Vite** (recommended for Vite users: no extension injection, no permissions, one line):
83
+
84
+ ```ts
85
+ // vite.config.ts
86
+ import { rerenderLens } from 'rerender-lens/vite';
87
+
88
+ export default defineConfig({
89
+ plugins: [react(), rerenderLens({ trackAllMemoized: true })],
90
+ });
91
+ ```
92
+
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.
97
+
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:
105
+
106
+ ```ts
107
+ // instrumentation-client.ts
108
+ import 'rerender-lens/setup';
109
+ ```
110
+
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):
123
+
124
+ ```sh
125
+ npx rerender-lens panel # http://127.0.0.1:4141/ serves the panel and relays messages
126
+ ```
127
+
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
+ ```
135
+
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
25
142
 
26
143
  ```ts
27
144
  // src/rerender-lens.ts
28
- import React from 'react'; // default import, not `import * as React`
29
145
  import { init } from 'rerender-lens';
30
146
 
31
147
  if (import.meta.env.DEV) {
32
- init(React, { trackAllMemoized: true });
148
+ init({ trackAllMemoized: true });
33
149
  }
34
150
  ```
35
151
 
@@ -40,31 +156,16 @@ import { createRoot } from 'react-dom/client';
40
156
  ...
41
157
  ```
42
158
 
43
- ### Automatic JSX runtime (Vite, Next, TS `react-jsx`)
44
-
45
- With the automatic runtime the compiler never calls `React.createElement`, so also point
46
- `jsxImportSource` at this package in development:
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.
47
162
 
48
- ```ts
49
- // vite.config.ts
50
- export default defineConfig(({ mode }) => ({
51
- esbuild: mode === 'development' ? { jsxImportSource: 'rerender-lens' } : undefined,
52
- plugins: [react({ jsxImportSource: mode === 'development' ? 'rerender-lens' : 'react' })],
53
- }));
54
- ```
55
-
56
- ```jsonc
57
- // tsconfig.json (or a tsconfig.dev.json)
58
- { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "rerender-lens" } }
59
- ```
60
-
61
- Anything still using `React.createElement` (the classic runtime, `React.cloneElement` of a new
62
- element, libraries) is covered by `init` alone.
163
+ No `jsxImportSource`, no default-import requirement, no Babel plugin.
63
164
 
64
165
  ## Choosing what to track
65
166
 
66
167
  ```ts
67
- init(React, {
168
+ init({
68
169
  trackAllMemoized: true, // every React.memo and PureComponent
69
170
  include: [/^Grid/, 'Sidebar'], // by display name: string, RegExp or predicate
70
171
  exclude: ['DevOverlay'],
@@ -81,7 +182,8 @@ export default track(memo(Sidebar));
81
182
  export const Cell = track((props: CellProps) => ..., 'Cell'); // name for anonymous arrows
82
183
  ```
83
184
 
84
- Marking sets the static `rerenderLens = true`; you can also set it by hand.
185
+ Marking sets the static `rerenderLens = true`; you can also set it by hand. `configure()`
186
+ changes any option at runtime without remounting anything.
85
187
 
86
188
  ## What a report contains
87
189
 
@@ -94,7 +196,20 @@ Every update of a tracked component produces a `RenderReport`:
94
196
  | `avoidable` | `true` when nothing genuinely changed |
95
197
  | `propChanges` | one entry per changed prop with `path`, `kind`, `prev`, `next` |
96
198
  | `stateChanges` | class components: `this.state` diff |
97
- | `hookChanges` | `useState`, `useReducer`, `useContext`, `useSyncExternalStore` values that changed |
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) |
98
213
  | `reasons` | human-readable explanations with the fix |
99
214
 
100
215
  Change kinds:
@@ -107,37 +222,101 @@ Change kinds:
107
222
  | `different` | a real change | none needed |
108
223
  | `added` / `removed` | key appeared or disappeared | usually a real change |
109
224
 
110
- A `parent` trigger with no changes at all means: identical props, the parent re-rendered.
225
+ A `parent` trigger with no changes at all means: identical props, an ancestor re-rendered.
111
226
  Wrap the component in `React.memo`.
112
227
 
113
- By default only avoidable re-renders are printed to the console; pass `logAll: true` to print
114
- every report. The `notifier` always receives every report.
228
+ By default only avoidable re-renders are printed; pass `logAll: true` to print every report.
229
+ The `notifier` always receives every report.
115
230
 
116
231
  ## Use in tests
117
232
 
118
233
  ```ts
119
- import React from 'react';
120
234
  import { init, disable, createCollector } from 'rerender-lens';
121
235
 
122
236
  const collector = createCollector();
123
- beforeAll(() => init(React, { trackAllMemoized: true, silent: true, notifier: collector.notifier }));
237
+ beforeAll(() => init({ trackAllMemoized: true, silent: true, notifier: collector.notifier }));
124
238
  afterAll(disable);
125
239
  beforeEach(collector.clear);
126
240
 
127
241
  test('typing in the search box does not re-render the grid rows', async () => {
128
242
  render(<ProductPage />);
129
243
  await user.type(screen.getByRole('searchbox'), 'abc');
130
- collector.assertNoAvoidable(); // throws with every component and reason
244
+ collector.assertNoAvoidable(); // throws listing every component and reason
131
245
  // or: expect(collector.avoidable).toHaveLength(0)
132
246
  });
133
247
  ```
134
248
 
135
- Import order matters here too: `init` must run before the modules under test read
136
- `React.useState` etc. A `setupFiles` entry in Vitest/Jest is the usual place.
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.
251
+
252
+ ### Vitest, whole suite
253
+
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:
256
+
257
+ ```ts
258
+ // vitest.config.ts
259
+ test: {
260
+ setupFiles: ['rerender-lens/vitest/setup'],
261
+ reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json', exportTo: 'rerender-lens.json' }]],
262
+ }
263
+ ```
264
+
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:
270
+
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
276
+ ```
277
+
278
+ ### Playwright, real browser
279
+
280
+ ```ts
281
+ 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
+ });
289
+ ```
290
+
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
297
+
298
+ ```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()
304
+ ```
305
+
306
+ The `rerender-lens` CLI does the same with files from the extension (Export) or from `summary()`:
307
+
308
+ ```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
312
+ 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)
315
+ ```
137
316
 
138
317
  ## Track a single component from the inside
139
318
 
140
- No patching, works anywhere, including inside library packages or Storybook:
319
+ Works anywhere without `init`, for example inside library packages or Storybook:
141
320
 
142
321
  ```ts
143
322
  import { useWhyRerender } from 'rerender-lens';
@@ -149,35 +328,60 @@ function Row(props: RowProps) {
149
328
  }
150
329
  ```
151
330
 
152
- Pass whatever values you want compared. Reports use the notifier from `init` when it was
153
- called, or the `options` third argument.
331
+ Pass whatever values you want compared.
154
332
 
155
333
  ## DevTools bridge
156
334
 
157
335
  ```ts
158
336
  import { createDevtoolsNotifier } from 'rerender-lens';
159
337
 
160
- init(React, { trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
338
+ init({ trackAllMemoized: true, silent: true, notifier: createDevtoolsNotifier() });
161
339
  ```
162
340
 
163
341
  Each report is serialized (functions become `ƒ name`, elements `<Type>`, cycles cut) and posted
164
- on `window` as `{ __rerenderLens: true, version: 1, type: 'report', payload }`. The last 300
165
- reports are buffered; `window.__RERENDER_LENS_DEVTOOLS__.replay()` re-posts them and `.clear()`
166
- drops them. Any extension or in-page panel can consume this.
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 | |
346
+ | --- | --- |
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`) |
353
+
354
+ The DevTools extension consumes this (see [Chrome extension](#chrome-extension) above); with
355
+ injection enabled it needs no `init` call at all.
356
+
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).
167
359
 
168
360
  ## API
169
361
 
170
362
  ```ts
171
- init(React, options?): () => void // returns disable
172
- configure(options): void // merge options at runtime
173
- disable(): void // restore React
363
+ init(options?): () => void // returns disable
364
+ configure(options): void // merge options at runtime
365
+ disable(): void
174
366
  isEnabled(): boolean
175
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'
176
374
  useWhyRerender(name, values, options?)
177
- createCollector(): { reports, avoidable, notifier, clear, assertNoAvoidable }
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)
178
379
  combineNotifiers(...notifiers): Notifier
179
- createDevtoolsNotifier({ bufferSize?, target?, maxDepth? }): Notifier
180
- deepEqual(a, b), diffRecords(prev, next), classify(prev, next) // the primitives
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)
181
385
  ```
182
386
 
183
387
  `Options`:
@@ -187,45 +391,64 @@ deepEqual(a, b), diffRecords(prev, next), classify(prev, next) // the primitiv
187
391
  | `trackAllMemoized` | `false` | track every `memo` / `PureComponent` |
188
392
  | `trackAllComponents` | `false` | track everything (noisy) |
189
393
  | `include` / `exclude` | | display-name matchers |
190
- | `trackHooks` | `true` | capture and diff hook values |
394
+ | `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 |
191
397
  | `logAll` | `false` | print non-avoidable reports too |
192
398
  | `silent` | `false` | never print; notifier still runs |
193
399
  | `notifier` | | receives every `RenderReport` |
194
400
  | `collapse` | `true` | `console.groupCollapsed` vs `console.group` |
195
401
  | `console` | `console` | sink for printing |
402
+ | `ignoreHotReload` | `true` | skip commits caused by Fast Refresh |
403
+ | `maxReportsPerComponent` | `0` | stop printing a component after N reports |
196
404
 
197
405
  ## How it works, and the caveats that follow
198
406
 
199
- - `init` replaces `React.createElement` with one that maps a tracked component type to a cached
200
- wrapper. The wrapper renders the original, keeps the previous props and hook values in a ref,
201
- and diffs them on the next render. Class components get a subclass whose `componentDidUpdate`
202
- diffs `props` and `state` and then calls yours.
203
- - Hook values are captured by patching `React.useState`, `useReducer`, `useContext` and
204
- `useSyncExternalStore`. This only reaches code that reads those functions **after** `init`
205
- ran, which is why the setup file must be imported first. With `import * as React`, the
206
- namespace is read-only in most bundlers; pass the default import.
207
- - A wrapper is a different element type from the original, so the first time a component
208
- becomes tracked (or stops being tracked after `configure`) it remounts. Decide tracking at
209
- startup; use `configure` for reporting options.
210
- - The mount render is never reported. Under `StrictMode` the duplicate dev render is detected
211
- (same props object, same hook values) and skipped.
212
- - `trackHooks: false` disables state-triggered reports for function components, because the
213
- wrapper then cannot distinguish a state update from a StrictMode re-invocation.
214
- - Diffing is structural and happens during render. It is meant for development; do not enable
215
- it in production builds.
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:
427
+
428
+ ```sh
429
+ npm install
430
+ npm --prefix examples/vite-react install
431
+ npm run dev:example
432
+ ```
433
+
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.
216
438
 
217
439
  ## Migrating from why-did-you-render
218
440
 
219
441
  | why-did-you-render | rerender-lens |
220
442
  | --- | --- |
221
- | `whyDidYouRender(React, opts)` | `init(React, opts)` |
443
+ | `whyDidYouRender(React, opts)` | `init(opts)` |
222
444
  | `trackAllPureComponents` | `trackAllMemoized` |
223
445
  | `Comp.whyDidYouRender = true` | `track(Comp)` or `Comp.rerenderLens = true` |
224
446
  | `include` / `exclude` (RegExp[]) | same, plus strings and predicates |
225
447
  | `trackHooks` | same |
226
448
  | `logOnDifferentValues` | `logAll` |
449
+ | `logOwnerReasons` | always on: `parent` and `owner` fields |
227
450
  | `notifier` (`{ Component, prevProps, ... }`) | `notifier` (`RenderReport`) |
228
- | `jsxImportSource: '@welldone-software/why-did-you-render'` | `jsxImportSource: 'rerender-lens'` |
451
+ | `jsxImportSource: '@welldone-software/why-did-you-render'` | not needed |
229
452
 
230
453
  ## License
231
454
 
@@ -0,0 +1,64 @@
1
+ import { R as RenderReport } from './types-BzUEVkxJ.cjs';
2
+
3
+ /**
4
+ * Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
5
+ * The same logic drives the extension's Fixes view; here it is typed on `RenderReport` for tests,
6
+ * CI budgets and the CLI. Pure.
7
+ */
8
+
9
+ type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
10
+ interface Fix {
11
+ kind: FixKind;
12
+ /** Where the change goes (the component that must be edited, or `<Ctx>.Provider`). */
13
+ owner: string;
14
+ /** The component whose re-render this removes. */
15
+ target: string;
16
+ prop: string | null;
17
+ label: string;
18
+ detail: string;
19
+ }
20
+ interface RankedFix extends Fix {
21
+ key: string;
22
+ /** Avoidable re-renders this fix removes. */
23
+ count: number;
24
+ components: Record<string, number>;
25
+ }
26
+ declare function fixesFor(r: RenderReport): Fix[];
27
+ declare const fixKey: (f: Fix) => string;
28
+ /** Every fix across `reports`, most re-renders removed first. */
29
+ declare function rankFixes(reports: RenderReport[]): RankedFix[];
30
+ /** Multi-line text for a test failure or a CI log. */
31
+ declare function formatFixes(reports: RenderReport[], limit?: number): string;
32
+
33
+ /**
34
+ * Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
35
+ * against a collector's reports (like a bundle-size budget). Pure; reading and writing the file is
36
+ * up to the test runner (`fs`), so this works in Node and in the browser.
37
+ */
38
+
39
+ /** `{ "*": 0, "ProductRow": 3 }`: max avoidable re-renders per component; `*` is the default. */
40
+ type Budget = Record<string, number>;
41
+ interface BudgetViolation {
42
+ component: string;
43
+ avoidable: number;
44
+ allowed: number;
45
+ }
46
+ interface BudgetResult {
47
+ ok: boolean;
48
+ violations: BudgetViolation[];
49
+ /** Components with headroom left, so budgets can be tightened. */
50
+ slack: {
51
+ component: string;
52
+ avoidable: number;
53
+ allowed: number;
54
+ }[];
55
+ counts: Record<string, number>;
56
+ }
57
+ declare function avoidableCounts(reports: RenderReport[]): Record<string, number>;
58
+ declare function checkBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
59
+ /** A budget that exactly matches the current counts (the baseline to commit). */
60
+ declare function toBudget(reports: RenderReport[]): Budget;
61
+ /** Throws with the violations and the ranked fixes; use it from a test. */
62
+ declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
63
+
64
+ export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };
@@ -0,0 +1,64 @@
1
+ import { R as RenderReport } from './types-BzUEVkxJ.js';
2
+
3
+ /**
4
+ * Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
5
+ * The same logic drives the extension's Fixes view; here it is typed on `RenderReport` for tests,
6
+ * CI budgets and the CLI. Pure.
7
+ */
8
+
9
+ type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
10
+ interface Fix {
11
+ kind: FixKind;
12
+ /** Where the change goes (the component that must be edited, or `<Ctx>.Provider`). */
13
+ owner: string;
14
+ /** The component whose re-render this removes. */
15
+ target: string;
16
+ prop: string | null;
17
+ label: string;
18
+ detail: string;
19
+ }
20
+ interface RankedFix extends Fix {
21
+ key: string;
22
+ /** Avoidable re-renders this fix removes. */
23
+ count: number;
24
+ components: Record<string, number>;
25
+ }
26
+ declare function fixesFor(r: RenderReport): Fix[];
27
+ declare const fixKey: (f: Fix) => string;
28
+ /** Every fix across `reports`, most re-renders removed first. */
29
+ declare function rankFixes(reports: RenderReport[]): RankedFix[];
30
+ /** Multi-line text for a test failure or a CI log. */
31
+ declare function formatFixes(reports: RenderReport[], limit?: number): string;
32
+
33
+ /**
34
+ * Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
35
+ * against a collector's reports (like a bundle-size budget). Pure; reading and writing the file is
36
+ * up to the test runner (`fs`), so this works in Node and in the browser.
37
+ */
38
+
39
+ /** `{ "*": 0, "ProductRow": 3 }`: max avoidable re-renders per component; `*` is the default. */
40
+ type Budget = Record<string, number>;
41
+ interface BudgetViolation {
42
+ component: string;
43
+ avoidable: number;
44
+ allowed: number;
45
+ }
46
+ interface BudgetResult {
47
+ ok: boolean;
48
+ violations: BudgetViolation[];
49
+ /** Components with headroom left, so budgets can be tightened. */
50
+ slack: {
51
+ component: string;
52
+ avoidable: number;
53
+ allowed: number;
54
+ }[];
55
+ counts: Record<string, number>;
56
+ }
57
+ declare function avoidableCounts(reports: RenderReport[]): Record<string, number>;
58
+ declare function checkBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
59
+ /** A budget that exactly matches the current counts (the baseline to commit). */
60
+ declare function toBudget(reports: RenderReport[]): Budget;
61
+ /** Throws with the violations and the ranked fixes; use it from a test. */
62
+ declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
63
+
64
+ export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };