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.
- package/CHANGELOG.md +164 -6
- package/README.md +298 -75
- package/dist/budget-COBu7jBU.d.cts +64 -0
- package/dist/budget-LkNjGtRc.d.ts +64 -0
- package/dist/cli.cjs +458 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +452 -0
- package/dist/cli.js.map +1 -0
- package/dist/devtools-BHGUUo-p.d.ts +244 -0
- package/dist/devtools-DmhWiWcN.d.cts +244 -0
- package/dist/index.cjs +1686 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -153
- package/dist/index.d.ts +67 -153
- package/dist/index.js +1643 -13
- package/dist/index.js.map +1 -1
- package/dist/notifiers-BGQtWKfX.d.cts +90 -0
- package/dist/notifiers-BjjGSHpp.d.ts +90 -0
- package/dist/playwright.cjs +196 -0
- package/dist/playwright.cjs.map +1 -0
- package/dist/playwright.d.cts +35 -0
- package/dist/playwright.d.ts +35 -0
- package/dist/playwright.js +184 -0
- package/dist/playwright.js.map +1 -0
- package/dist/relay.cjs +166 -0
- package/dist/relay.cjs.map +1 -0
- package/dist/relay.d.cts +41 -0
- package/dist/relay.d.ts +41 -0
- package/dist/relay.js +161 -0
- package/dist/relay.js.map +1 -0
- package/dist/rerender-lens.iife.js +1717 -0
- package/dist/setup.cjs +1510 -0
- package/dist/setup.cjs.map +1 -0
- package/dist/setup.d.cts +2 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +1508 -0
- package/dist/setup.js.map +1 -0
- package/dist/types-BzUEVkxJ.d.cts +177 -0
- package/dist/types-BzUEVkxJ.d.ts +177 -0
- package/dist/vite.cjs +113 -0
- package/dist/vite.cjs.map +1 -0
- package/dist/vite.d.cts +84 -0
- package/dist/vite.d.ts +84 -0
- package/dist/vite.js +103 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-setup.cjs +1299 -0
- package/dist/vitest-setup.cjs.map +1 -0
- package/dist/vitest-setup.d.cts +8 -0
- package/dist/vitest-setup.d.ts +8 -0
- package/dist/vitest-setup.js +1297 -0
- package/dist/vitest-setup.js.map +1 -0
- package/dist/vitest.cjs +1360 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +52 -0
- package/dist/vitest.d.ts +52 -0
- package/dist/vitest.js +1351 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +83 -17
- package/panel/panel.css +418 -0
- package/panel/panel.html +12 -0
- package/panel/panel.js +3325 -0
- package/dist/chunk-3YHI7BCU.js +0 -544
- package/dist/chunk-3YHI7BCU.js.map +0 -1
- package/dist/chunk-DOYB4MKH.cjs +0 -562
- package/dist/chunk-DOYB4MKH.cjs.map +0 -1
- package/dist/jsx-dev-runtime.cjs +0 -32
- package/dist/jsx-dev-runtime.cjs.map +0 -1
- package/dist/jsx-dev-runtime.d.cts +0 -8
- package/dist/jsx-dev-runtime.d.ts +0 -8
- package/dist/jsx-dev-runtime.js +0 -9
- package/dist/jsx-dev-runtime.js.map +0 -1
- package/dist/jsx-runtime.cjs +0 -34
- package/dist/jsx-runtime.cjs.map +0 -1
- package/dist/jsx-runtime.d.cts +0 -9
- package/dist/jsx-runtime.d.ts +0 -9
- package/dist/jsx-runtime.js +0 -10
- 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
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
80
|
+
## One-line setup per bundler
|
|
22
81
|
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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(
|
|
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`, `
|
|
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,
|
|
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
|
|
114
|
-
|
|
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(
|
|
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
|
|
244
|
+
collector.assertNoAvoidable(); // throws listing every component and reason
|
|
131
245
|
// or: expect(collector.avoidable).toHaveLength(0)
|
|
132
246
|
});
|
|
133
247
|
```
|
|
134
248
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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:
|
|
165
|
-
reports are buffered
|
|
166
|
-
|
|
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(
|
|
172
|
-
configure(options): void
|
|
173
|
-
disable(): void
|
|
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
|
-
|
|
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` |
|
|
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
|
-
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
`useSyncExternalStore
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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'` |
|
|
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 };
|