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