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.
- package/CHANGELOG.md +78 -0
- package/README.md +192 -362
- package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
- package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
- package/dist/cli.cjs +248 -31
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +248 -31
- package/dist/cli.js.map +1 -1
- package/dist/{devtools-CZebzpF6.d.ts → devtools-BYWat7nQ.d.ts} +7 -1
- package/dist/{devtools-BkCct3cJ.d.cts → devtools-DJi_6AfJ.d.cts} +7 -1
- package/dist/index.cjs +439 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -8
- package/dist/index.d.ts +131 -8
- package/dist/index.js +424 -42
- package/dist/index.js.map +1 -1
- package/dist/jest-setup.cjs +1673 -0
- package/dist/jest-setup.cjs.map +1 -0
- package/dist/jest-setup.d.cts +8 -0
- package/dist/jest-setup.d.ts +8 -0
- package/dist/jest-setup.js +1671 -0
- package/dist/jest-setup.js.map +1 -0
- package/dist/jest.cjs +1760 -0
- package/dist/jest.cjs.map +1 -0
- package/dist/jest.d.cts +23 -0
- package/dist/jest.d.ts +23 -0
- package/dist/jest.js +1751 -0
- package/dist/jest.js.map +1 -0
- package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
- package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.d.ts} +8 -4
- package/dist/playwright.cjs +230 -28
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +4 -4
- package/dist/playwright.d.ts +4 -4
- package/dist/playwright.js +230 -28
- package/dist/playwright.js.map +1 -1
- package/dist/relay.cjs +10 -2
- package/dist/relay.cjs.map +1 -1
- package/dist/relay.js +10 -2
- package/dist/relay.js.map +1 -1
- package/dist/rerender-lens.iife.js +356 -54
- package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
- package/dist/runner-report-CW8X5RwK.d.ts +46 -0
- package/dist/setup.cjs +112 -27
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +112 -27
- package/dist/setup.js.map +1 -1
- package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
- package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vitest-setup.cjs +337 -41
- package/dist/vitest-setup.cjs.map +1 -1
- package/dist/vitest-setup.d.cts +4 -4
- package/dist/vitest-setup.d.ts +4 -4
- package/dist/vitest-setup.js +337 -41
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +400 -77
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +21 -39
- package/dist/vitest.d.ts +21 -39
- package/dist/vitest.js +401 -78
- package/dist/vitest.js.map +1 -1
- package/package.json +40 -4
- package/panel/panel.css +1 -0
- 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,
|
|
4
|
-
which
|
|
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
|
|
7
|
-
Nothing in React is patched
|
|
8
|
-
|
|
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
|
|
14
|
-
- prop "onSelect" is a new function instance on every render: wrap it in useCallback
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
95
|
-
`
|
|
96
|
-
`
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
100
|
+
## Tests and CI
|
|
254
101
|
|
|
255
|
-
|
|
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'
|
|
107
|
+
reporters: ['default', ['rerender-lens/vitest', { budget: 'rerender-budget.json' }]],
|
|
263
108
|
}
|
|
264
109
|
```
|
|
265
110
|
|
|
266
|
-
The
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
144
|
+
**CLI**, on a panel export or a session summary:
|
|
308
145
|
|
|
309
146
|
```sh
|
|
310
|
-
npx rerender-lens fixes export.json
|
|
311
|
-
npx rerender-lens
|
|
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
|
|
315
|
-
npx rerender-lens
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
353
|
-
| `
|
|
354
|
-
| `
|
|
355
|
-
| `
|
|
356
|
-
| `
|
|
357
|
-
| `
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
##
|
|
180
|
+
## What counts as avoidable
|
|
366
181
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|
401
|
-
| `resolveHookNames` | `false` | label hooks with the custom hooks that own them (`useCart › useState#0`)
|
|
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
|
-
##
|
|
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
|
-
```
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
`
|
|
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`
|
|
459
|
-
| `notifier
|
|
460
|
-
| `jsxImportSource
|
|
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
|
|