rerender-lens 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +192 -353
- 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-BHGUUo-p.d.ts → devtools-BYWat7nQ.d.ts} +17 -3
- package/dist/{devtools-DmhWiWcN.d.cts → devtools-DJi_6AfJ.d.cts} +17 -3
- package/dist/index.cjs +635 -109
- 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 +620 -110
- 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 +235 -30
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +8 -6
- package/dist/playwright.d.ts +8 -6
- package/dist/playwright.js +235 -31
- 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 +1100 -337
- package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
- package/dist/runner-report-CW8X5RwK.d.ts +46 -0
- package/dist/setup.cjs +308 -95
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +308 -95
- 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 +452 -87
- 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 +452 -87
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +515 -123
- 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 +516 -124
- package/dist/vitest.js.map +1 -1
- package/package.json +41 -5
- package/panel/panel.css +1 -34
- package/panel/panel.js +865 -713
package/dist/vitest.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
2
2
|
import { tmpdir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
|
-
// src/
|
|
5
|
+
// src/runner-report.ts
|
|
6
6
|
|
|
7
7
|
// src/diff.ts
|
|
8
8
|
var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
|
|
@@ -27,10 +27,49 @@ function remember(seen, a, b) {
|
|
|
27
27
|
set.add(b);
|
|
28
28
|
return false;
|
|
29
29
|
}
|
|
30
|
+
var scope = null;
|
|
31
|
+
var DEFAULT_DIFF_BUDGET = 5e4;
|
|
32
|
+
function beginDiffScope(budget = 2e5) {
|
|
33
|
+
const previous = scope;
|
|
34
|
+
scope = { cache: /* @__PURE__ */ new WeakMap(), remaining: budget, exhausted: false };
|
|
35
|
+
return () => {
|
|
36
|
+
scope = previous;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function diffBudgetExhausted() {
|
|
40
|
+
return scope?.exhausted === true;
|
|
41
|
+
}
|
|
30
42
|
function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
|
|
31
43
|
if (Object.is(a, b)) return true;
|
|
32
44
|
if (typeof a !== typeof b) return false;
|
|
33
45
|
if (typeof a !== "object" || a === null || b === null) return false;
|
|
46
|
+
if (!scope) {
|
|
47
|
+
const end = beginDiffScope(DEFAULT_DIFF_BUDGET);
|
|
48
|
+
try {
|
|
49
|
+
return deepEqual(a, b, seen);
|
|
50
|
+
} finally {
|
|
51
|
+
end();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const sc = scope;
|
|
55
|
+
const objA = a;
|
|
56
|
+
const objB = b;
|
|
57
|
+
const hit = sc.cache.get(objA)?.get(objB);
|
|
58
|
+
if (hit !== void 0) return hit;
|
|
59
|
+
if (--sc.remaining < 0) {
|
|
60
|
+
sc.exhausted = true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const result = deepEqualObjects(objA, objB, seen);
|
|
64
|
+
let m = sc.cache.get(objA);
|
|
65
|
+
if (!m) {
|
|
66
|
+
m = /* @__PURE__ */ new WeakMap();
|
|
67
|
+
sc.cache.set(objA, m);
|
|
68
|
+
}
|
|
69
|
+
m.set(objB, result);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function deepEqualObjects(a, b, seen) {
|
|
34
73
|
const objA = a;
|
|
35
74
|
const objB = b;
|
|
36
75
|
if (remember(seen, objA, objB)) return true;
|
|
@@ -59,6 +98,10 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
|
|
|
59
98
|
}
|
|
60
99
|
if (a instanceof Set) {
|
|
61
100
|
if (!(b instanceof Set) || a.size !== b.size) return false;
|
|
101
|
+
if (a.size > 50) {
|
|
102
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
62
105
|
outer: for (const v of a) {
|
|
63
106
|
if (b.has(v)) continue;
|
|
64
107
|
for (const w of b) if (deepEqual(v, w, seen)) continue outer;
|
|
@@ -74,14 +117,16 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
|
|
|
74
117
|
const ka = Object.keys(a);
|
|
75
118
|
const kb = Object.keys(b);
|
|
76
119
|
if (ka.length !== kb.length) return false;
|
|
120
|
+
const rb = b;
|
|
77
121
|
for (const k of ka) {
|
|
78
122
|
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
|
79
|
-
if (!deepEqual(a[k],
|
|
123
|
+
if (!deepEqual(a[k], rb[k], seen)) return false;
|
|
80
124
|
}
|
|
81
125
|
return true;
|
|
82
126
|
}
|
|
83
127
|
return false;
|
|
84
128
|
}
|
|
129
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
85
130
|
function classify(prev, next) {
|
|
86
131
|
if (typeof prev === "function" && typeof next === "function") {
|
|
87
132
|
return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
|
|
@@ -118,30 +163,30 @@ function diffRecords(prev, next, basePath = "") {
|
|
|
118
163
|
if (Object.is(a, b)) continue;
|
|
119
164
|
const kind = classify(a, b);
|
|
120
165
|
if (kind === "different") {
|
|
121
|
-
changes.push({ path: firstDifferentPath(a, b, path), kind, prev: a, next: b });
|
|
166
|
+
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
|
|
122
167
|
} else {
|
|
123
168
|
changes.push({ path, kind, prev: a, next: b });
|
|
124
169
|
}
|
|
125
170
|
}
|
|
126
171
|
return changes;
|
|
127
172
|
}
|
|
128
|
-
function firstDifferentPath(a, b, path, depth = 0) {
|
|
129
|
-
if (depth > 8) return path;
|
|
173
|
+
function firstDifferentPath(a, b, path = "", depth = 0) {
|
|
174
|
+
if (depth > 8) return path || "(value)";
|
|
130
175
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
131
|
-
if (a.length !== b.length) return `${path}.length
|
|
176
|
+
if (a.length !== b.length) return path ? `${path}.length` : "length";
|
|
132
177
|
for (let i = 0; i < a.length; i++) {
|
|
133
178
|
if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
|
|
134
179
|
}
|
|
135
|
-
return
|
|
180
|
+
return null;
|
|
136
181
|
}
|
|
137
182
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
138
183
|
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
139
184
|
for (const k of keys) {
|
|
140
185
|
if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
|
|
141
186
|
}
|
|
142
|
-
return
|
|
187
|
+
return null;
|
|
143
188
|
}
|
|
144
|
-
return path;
|
|
189
|
+
return deepEqual(a, b) ? null : path || "(value)";
|
|
145
190
|
}
|
|
146
191
|
|
|
147
192
|
// src/report.ts
|
|
@@ -159,10 +204,12 @@ function storeAdvice(custom) {
|
|
|
159
204
|
}
|
|
160
205
|
var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
161
206
|
var isChildren = (change) => change.path === "children";
|
|
207
|
+
var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
|
|
162
208
|
function fixFor(change) {
|
|
163
209
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
164
210
|
return `lift the children out of the parent's render: memoize them with useMemo, hoist static elements to module scope, or render them from a component that does not re-render`;
|
|
165
211
|
}
|
|
212
|
+
if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
|
|
166
213
|
switch (change.kind) {
|
|
167
214
|
case "function":
|
|
168
215
|
return `wrap it in useCallback (or hoist it out of the parent's render)`;
|
|
@@ -178,6 +225,7 @@ function describe(change) {
|
|
|
178
225
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
179
226
|
return `children are new React elements with the same types and props on every render of the parent`;
|
|
180
227
|
}
|
|
228
|
+
if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
|
|
181
229
|
switch (change.kind) {
|
|
182
230
|
case "deep-equal":
|
|
183
231
|
return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
|
|
@@ -205,11 +253,21 @@ function buildReport(input) {
|
|
|
205
253
|
if (causes.length === 0) trigger = "parent";
|
|
206
254
|
else if (causes.length === 1) trigger = causes[0];
|
|
207
255
|
else trigger = "mixed";
|
|
208
|
-
const
|
|
256
|
+
const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
|
|
257
|
+
const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
|
|
258
|
+
const revealed = input.revealed === true && trigger === "parent";
|
|
259
|
+
const avoidable = trigger === "parent" && !cacheHit && !revealed;
|
|
209
260
|
const reasons = [];
|
|
210
|
-
|
|
261
|
+
const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
|
|
262
|
+
if (cacheHit) {
|
|
263
|
+
reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
|
|
264
|
+
} else if (revealed) {
|
|
265
|
+
reasons.push(
|
|
266
|
+
`re-rendered because the Suspense boundary above it switched from its fallback to content: React re-renders the content it kept hidden while the fallback was shown. A one-off; nothing to fix.`
|
|
267
|
+
);
|
|
268
|
+
} else if (avoidable && nothingChanged) {
|
|
211
269
|
const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
|
|
212
|
-
reasons.push(`re-rendered with identical props because ${who}.
|
|
270
|
+
reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
|
|
213
271
|
} else if (avoidable && input.parent) {
|
|
214
272
|
reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
|
|
215
273
|
}
|
|
@@ -218,9 +276,9 @@ function buildReport(input) {
|
|
|
218
276
|
reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
|
|
219
277
|
}
|
|
220
278
|
const memoized = input.memoized !== false;
|
|
221
|
-
if (avoidable && !memoized && input.propChanges.length > 0) {
|
|
279
|
+
if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
|
|
222
280
|
reasons.push(
|
|
223
|
-
`"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also wrap it in React.memo (or extend PureComponent).`
|
|
281
|
+
`"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also ${input.classComponent ? "extend PureComponent (or implement shouldComponentUpdate)" : "wrap it in React.memo (or extend PureComponent)"}.`
|
|
224
282
|
);
|
|
225
283
|
}
|
|
226
284
|
for (const c of stateChanges) {
|
|
@@ -232,6 +290,8 @@ function buildReport(input) {
|
|
|
232
290
|
const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
|
|
233
291
|
const keys = c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys ? `: only ${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed, yet every consumer re-renders. Split the context or memoize the slices consumers read` : "";
|
|
234
292
|
reasons.push(`${c.path} changed${where}${keys}.`);
|
|
293
|
+
} else if (c.hook === "useDeferredValue" && isGenuine(c)) {
|
|
294
|
+
reasons.push(`${hookLabel(c)} caught up with its latest value: this is the deferred second render React schedules after the urgent one; nothing to fix.`);
|
|
235
295
|
} else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
|
|
236
296
|
else if (isStateHook(c))
|
|
237
297
|
reasons.push(`${hookLabel(c)} was set to a value deep-equal to the current one (new reference, same contents): reuse the existing object or bail out before calling the setter.`);
|
|
@@ -262,6 +322,7 @@ function buildReport(input) {
|
|
|
262
322
|
reasons,
|
|
263
323
|
time: now()
|
|
264
324
|
};
|
|
325
|
+
if (input.compiled) report.compiled = true;
|
|
265
326
|
if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
|
|
266
327
|
if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
|
|
267
328
|
if (input.commitPriority) report.commitPriority = input.commitPriority;
|
|
@@ -299,10 +360,10 @@ var KIND_LABEL = {
|
|
|
299
360
|
};
|
|
300
361
|
function summarize(report) {
|
|
301
362
|
const counts2 = /* @__PURE__ */ new Map();
|
|
302
|
-
for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
|
|
363
|
+
for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
|
|
303
364
|
counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
|
|
304
365
|
}
|
|
305
|
-
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
|
|
366
|
+
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
|
|
306
367
|
if (parts.length === 0) parts.push("no changes");
|
|
307
368
|
return parts.join(", ");
|
|
308
369
|
}
|
|
@@ -329,7 +390,7 @@ function getState() {
|
|
|
329
390
|
const g = globalThis;
|
|
330
391
|
let s = g[KEY];
|
|
331
392
|
if (!s) {
|
|
332
|
-
s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0 };
|
|
393
|
+
s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0, truncated: 0 };
|
|
333
394
|
g[KEY] = s;
|
|
334
395
|
}
|
|
335
396
|
return s;
|
|
@@ -359,7 +420,7 @@ function dispatch(report, override) {
|
|
|
359
420
|
try {
|
|
360
421
|
options.notifier(report);
|
|
361
422
|
} catch (err) {
|
|
362
|
-
(
|
|
423
|
+
warnOnce("notifier", `notifier threw: ${String(err)}`);
|
|
363
424
|
}
|
|
364
425
|
}
|
|
365
426
|
}
|
|
@@ -426,6 +487,27 @@ function disable() {
|
|
|
426
487
|
}
|
|
427
488
|
|
|
428
489
|
// src/hookNames.ts
|
|
490
|
+
var NODE_COUNT = {
|
|
491
|
+
useState: 1,
|
|
492
|
+
useReducer: 1,
|
|
493
|
+
useRef: 1,
|
|
494
|
+
useMemo: 1,
|
|
495
|
+
useCallback: 1,
|
|
496
|
+
useEffect: 1,
|
|
497
|
+
useLayoutEffect: 1,
|
|
498
|
+
useInsertionEffect: 1,
|
|
499
|
+
useImperativeHandle: 1,
|
|
500
|
+
useSyncExternalStore: 2,
|
|
501
|
+
useTransition: 2,
|
|
502
|
+
useDeferredValue: 1,
|
|
503
|
+
useId: 1,
|
|
504
|
+
useOptimistic: 1,
|
|
505
|
+
useActionState: 3,
|
|
506
|
+
useFormState: 3,
|
|
507
|
+
useEffectEvent: 1,
|
|
508
|
+
useCacheRefresh: 1
|
|
509
|
+
};
|
|
510
|
+
var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
|
|
429
511
|
var noop = () => {
|
|
430
512
|
};
|
|
431
513
|
function customHooksFromStack(stack, componentName, marker) {
|
|
@@ -440,7 +522,7 @@ function customHooksFromStack(stack, componentName, marker) {
|
|
|
440
522
|
continue;
|
|
441
523
|
}
|
|
442
524
|
if (name === componentName || name.endsWith("." + componentName)) break;
|
|
443
|
-
if (/^use[A-Z0-9_]/.test(name)) names.push(name);
|
|
525
|
+
if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
|
|
444
526
|
if (names.length > 8) break;
|
|
445
527
|
}
|
|
446
528
|
return names;
|
|
@@ -724,6 +806,11 @@ function isMemoizedFiber(fiber) {
|
|
|
724
806
|
}
|
|
725
807
|
return false;
|
|
726
808
|
}
|
|
809
|
+
function isCompiledFiber(fiber) {
|
|
810
|
+
if (fiber.tag === ClassComponent) return false;
|
|
811
|
+
const cache2 = fiber.updateQueue?.memoCache;
|
|
812
|
+
return !!cache2 && Array.isArray(cache2.data);
|
|
813
|
+
}
|
|
727
814
|
function durationsOf(fiber) {
|
|
728
815
|
if (typeof fiber.actualDuration !== "number") return null;
|
|
729
816
|
const tree = fiber.actualDuration;
|
|
@@ -747,11 +834,14 @@ var counts = /* @__PURE__ */ new WeakMap();
|
|
|
747
834
|
var ids = /* @__PURE__ */ new WeakMap();
|
|
748
835
|
var fibersById = /* @__PURE__ */ new Map();
|
|
749
836
|
var hasWeakRef = typeof WeakRef === "function";
|
|
837
|
+
var pruneAt = 5e3;
|
|
750
838
|
function remember2(id, fiber) {
|
|
751
839
|
if (!hasWeakRef) return;
|
|
840
|
+
if (fibersById.get(id)?.deref() === fiber) return;
|
|
752
841
|
fibersById.set(id, new WeakRef(fiber));
|
|
753
|
-
if (fibersById.size >
|
|
842
|
+
if (fibersById.size > pruneAt) {
|
|
754
843
|
for (const [k, ref] of fibersById) if (!ref.deref()) fibersById.delete(k);
|
|
844
|
+
pruneAt = Math.max(5e3, fibersById.size * 2);
|
|
755
845
|
}
|
|
756
846
|
}
|
|
757
847
|
function instanceIdOf(fiber) {
|
|
@@ -780,7 +870,7 @@ function nearestComponent(fiber) {
|
|
|
780
870
|
}
|
|
781
871
|
var INTERNAL_FRAME = /react-dom|react_jsx|jsx-(dev-)?runtime|\/react\/|node_modules\/react|react-stack-bottom-frame|react_stack_bottom_frame|scheduler/;
|
|
782
872
|
function parseStackLocation(stack) {
|
|
783
|
-
for (const line of stack.split("\n")) {
|
|
873
|
+
for (const line of stack.split("\n", 24)) {
|
|
784
874
|
const m = /(?:at\s+(?:.*?\s+)?\(?|@)?((?:https?|file|webpack|vite|blob):[^\s()]+?):(\d+):(\d+)\)?\s*$/.exec(line.trim());
|
|
785
875
|
if (!m || !m[1] || INTERNAL_FRAME.test(m[1])) continue;
|
|
786
876
|
return { fileName: m[1], lineNumber: Number(m[2]), columnNumber: Number(m[3]) };
|
|
@@ -796,9 +886,16 @@ function sourceOf(fiber) {
|
|
|
796
886
|
return out;
|
|
797
887
|
}
|
|
798
888
|
const st = fiber._debugStack;
|
|
799
|
-
|
|
800
|
-
|
|
889
|
+
if (st && typeof st === "object") {
|
|
890
|
+
if (sourceCache.has(st)) return sourceCache.get(st);
|
|
891
|
+
const text = typeof st.stack === "string" ? st.stack : null;
|
|
892
|
+
const out = text ? parseStackLocation(text) : void 0;
|
|
893
|
+
sourceCache.set(st, out);
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
return typeof st === "string" ? parseStackLocation(st) : void 0;
|
|
801
897
|
}
|
|
898
|
+
var sourceCache = /* @__PURE__ */ new WeakMap();
|
|
802
899
|
function bumpCount(fiber) {
|
|
803
900
|
const prev = counts.get(fiber) ?? (fiber.alternate ? counts.get(fiber.alternate) : void 0) ?? 0;
|
|
804
901
|
const n = prev + 1;
|
|
@@ -866,7 +963,8 @@ function diffHooks(fiber, alt) {
|
|
|
866
963
|
const labels = hookLabelsFor(fiber, b);
|
|
867
964
|
let i = 0;
|
|
868
965
|
while (a && b) {
|
|
869
|
-
|
|
966
|
+
const deferred = labels?.[i] === "useDeferredValue";
|
|
967
|
+
if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
|
|
870
968
|
const hook = labels?.[i] ?? hookLabel2(b);
|
|
871
969
|
out.push({
|
|
872
970
|
path: `${hook}#${i}`,
|
|
@@ -900,6 +998,10 @@ function diffContexts(fiber, alt) {
|
|
|
900
998
|
let a = alt.dependencies?.firstContext ?? null;
|
|
901
999
|
let b = fiber.dependencies?.firstContext ?? null;
|
|
902
1000
|
let i = 0;
|
|
1001
|
+
if (b && !("memoizedValue" in b)) {
|
|
1002
|
+
warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
|
|
1003
|
+
return out;
|
|
1004
|
+
}
|
|
903
1005
|
while (a && b) {
|
|
904
1006
|
if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
|
|
905
1007
|
const name = b.context.displayName ?? "Context";
|
|
@@ -932,10 +1034,26 @@ function diffContexts(fiber, alt) {
|
|
|
932
1034
|
return out;
|
|
933
1035
|
}
|
|
934
1036
|
var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
1037
|
+
var isRefObject2 = (v) => {
|
|
1038
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1039
|
+
const keys = Object.keys(v);
|
|
1040
|
+
return keys.length === 1 && keys[0] === "current";
|
|
1041
|
+
};
|
|
1042
|
+
function diffProps(prev, next) {
|
|
1043
|
+
if (prev === next) return [];
|
|
1044
|
+
const pr = prev.ref;
|
|
1045
|
+
const nr = next.ref;
|
|
1046
|
+
if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
|
|
1047
|
+
const { ref: _p, ...restPrev } = prev;
|
|
1048
|
+
const { ref: _n, ...restNext } = next;
|
|
1049
|
+
return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
|
|
1050
|
+
}
|
|
1051
|
+
return diffRecords(prev, next);
|
|
1052
|
+
}
|
|
935
1053
|
function analyze(fiber, alt, trackHooks) {
|
|
936
1054
|
const prevProps = alt.memoizedProps ?? {};
|
|
937
1055
|
const nextProps = fiber.memoizedProps ?? {};
|
|
938
|
-
const propChanges =
|
|
1056
|
+
const propChanges = diffProps(prevProps, nextProps);
|
|
939
1057
|
let stateChanges = [];
|
|
940
1058
|
let hookChanges = [];
|
|
941
1059
|
if (fiber.tag === ClassComponent) {
|
|
@@ -948,7 +1066,7 @@ function analyze(fiber, alt, trackHooks) {
|
|
|
948
1066
|
} else if (trackHooks) {
|
|
949
1067
|
hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
|
|
950
1068
|
}
|
|
951
|
-
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
|
|
1069
|
+
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
|
|
952
1070
|
const causes = [];
|
|
953
1071
|
if (propChanges.some(isGenuine2)) causes.push("props");
|
|
954
1072
|
if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
|
|
@@ -986,6 +1104,26 @@ function updatersOf(root) {
|
|
|
986
1104
|
}
|
|
987
1105
|
var lastCommit = null;
|
|
988
1106
|
var EFFECT_LOOP_WINDOW_MS = 50;
|
|
1107
|
+
var MAX_REPORTS_PER_COMMIT = 200;
|
|
1108
|
+
var COMMIT_TIME_BUDGET_MS = 25;
|
|
1109
|
+
function isDeferredCatchUp(fiber) {
|
|
1110
|
+
const types = fiber._debugHookTypes;
|
|
1111
|
+
if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
|
|
1112
|
+
const first = fiber.memoizedState;
|
|
1113
|
+
if (!isHookList(first)) return false;
|
|
1114
|
+
const labels = hookLabelsFor(fiber, first);
|
|
1115
|
+
if (!labels) return false;
|
|
1116
|
+
let a = fiber.alternate.memoizedState;
|
|
1117
|
+
let b = first;
|
|
1118
|
+
for (let i = 0; a && b; a = a.next, b = b.next, i++) {
|
|
1119
|
+
if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
|
|
1120
|
+
}
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
function isUnder(fiber, boundaries) {
|
|
1124
|
+
for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
989
1127
|
function onCommit(root, commitPriority) {
|
|
990
1128
|
const s = getState();
|
|
991
1129
|
if (!s.enabled) return;
|
|
@@ -995,7 +1133,7 @@ function onCommit(root, commitPriority) {
|
|
|
995
1133
|
const rendered = [];
|
|
996
1134
|
const renderedNames = /* @__PURE__ */ new Set();
|
|
997
1135
|
let hot = false;
|
|
998
|
-
let
|
|
1136
|
+
let resolvedBoundaries = null;
|
|
999
1137
|
const stack = [root.current];
|
|
1000
1138
|
while (stack.length) {
|
|
1001
1139
|
const fiber = stack.pop();
|
|
@@ -1005,7 +1143,7 @@ function onCommit(root, commitPriority) {
|
|
|
1005
1143
|
renderedNames.add(fiberName(fiber));
|
|
1006
1144
|
if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
|
|
1007
1145
|
}
|
|
1008
|
-
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null)
|
|
1146
|
+
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
|
|
1009
1147
|
if (!alt || fiber.child !== alt.child) {
|
|
1010
1148
|
for (let c = fiber.child; c; c = c.sibling) stack.push(c);
|
|
1011
1149
|
}
|
|
@@ -1016,10 +1154,11 @@ function onCommit(root, commitPriority) {
|
|
|
1016
1154
|
const updaters = updatersOf(root);
|
|
1017
1155
|
let commitCause;
|
|
1018
1156
|
let afterCommit;
|
|
1019
|
-
|
|
1157
|
+
const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
|
|
1158
|
+
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
|
|
1020
1159
|
commitCause = "effect-after-commit";
|
|
1021
1160
|
afterCommit = lastCommit.id;
|
|
1022
|
-
} else if (
|
|
1161
|
+
} else if (resolvedBoundaries) commitCause = "suspense-resolved";
|
|
1023
1162
|
if (rendered.length === 0) {
|
|
1024
1163
|
lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
|
|
1025
1164
|
return;
|
|
@@ -1028,82 +1167,262 @@ function onCommit(root, commitPriority) {
|
|
|
1028
1167
|
lastCommit = { id: commitId, at, rendered: renderedNames };
|
|
1029
1168
|
const dispatcherRef = o.resolveHookNames ? getDispatcherRef() : null;
|
|
1030
1169
|
const parentCache = /* @__PURE__ */ new Map();
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1170
|
+
const endDiffScope = beginDiffScope();
|
|
1171
|
+
const deadline = at + COMMIT_TIME_BUDGET_MS;
|
|
1172
|
+
try {
|
|
1173
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
1174
|
+
if (i >= MAX_REPORTS_PER_COMMIT || (i & 15) === 15 && nowMs() > deadline) {
|
|
1175
|
+
s.truncated += rendered.length - i;
|
|
1176
|
+
warnOnce("truncated", `a commit rendered ${rendered.length} tracked components; only ${i} were reported (see info().truncated). Narrow \`include\` or turn off includeState.`);
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
const fiber = rendered[i];
|
|
1180
|
+
const alt = fiber.alternate;
|
|
1181
|
+
const a = analyze(fiber, alt, trackHooks);
|
|
1182
|
+
const durations = durationsOf(fiber);
|
|
1183
|
+
let hookState = o.includeState !== false && trackHooks ? snapshotHooks(fiber) : void 0;
|
|
1184
|
+
if (dispatcherRef && fiber.tag !== ClassComponent && (a.hookChanges.length || hookState && hookState.length)) {
|
|
1185
|
+
const names = resolveHookNames(fiber, dispatcherRef);
|
|
1186
|
+
if (names.size) {
|
|
1187
|
+
for (const c of a.hookChanges) if (c.hook !== "useContext" && names.has(c.index)) c.custom = names.get(c.index);
|
|
1188
|
+
if (hookState) hookState = hookState.map((h) => names.has(h.index) ? { ...h, custom: names.get(h.index) } : h);
|
|
1189
|
+
}
|
|
1041
1190
|
}
|
|
1191
|
+
const report = buildReport({
|
|
1192
|
+
component: fiberName(fiber),
|
|
1193
|
+
instanceId: instanceId(fiber),
|
|
1194
|
+
renderCount: bumpCount(fiber),
|
|
1195
|
+
prevProps: alt.memoizedProps ?? {},
|
|
1196
|
+
nextProps: fiber.memoizedProps ?? {},
|
|
1197
|
+
propChanges: a.propChanges,
|
|
1198
|
+
stateChanges: a.stateChanges,
|
|
1199
|
+
hookChanges: a.hookChanges,
|
|
1200
|
+
...o.includeState !== false ? {
|
|
1201
|
+
hookState,
|
|
1202
|
+
contexts: trackHooks ? snapshotContexts(fiber) : void 0,
|
|
1203
|
+
state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
|
|
1204
|
+
} : {},
|
|
1205
|
+
updaters,
|
|
1206
|
+
commitCause,
|
|
1207
|
+
afterCommit,
|
|
1208
|
+
key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
|
|
1209
|
+
parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
|
|
1210
|
+
owner: ownerName(fiber),
|
|
1211
|
+
path: componentPath(fiber),
|
|
1212
|
+
memoized: isMemoizedFiber(fiber),
|
|
1213
|
+
compiled: isCompiledFiber(fiber),
|
|
1214
|
+
classComponent: fiber.tag === ClassComponent,
|
|
1215
|
+
revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
|
|
1216
|
+
selfDuration: durations ? durations.self : void 0,
|
|
1217
|
+
treeDuration: durations ? durations.tree : void 0,
|
|
1218
|
+
commitId,
|
|
1219
|
+
commitPriority,
|
|
1220
|
+
source: sourceOf(fiber)
|
|
1221
|
+
});
|
|
1222
|
+
dispatch(report);
|
|
1042
1223
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
instanceId: instanceId(fiber),
|
|
1046
|
-
renderCount: bumpCount(fiber),
|
|
1047
|
-
prevProps: alt.memoizedProps ?? {},
|
|
1048
|
-
nextProps: fiber.memoizedProps ?? {},
|
|
1049
|
-
propChanges: a.propChanges,
|
|
1050
|
-
stateChanges: a.stateChanges,
|
|
1051
|
-
hookChanges: a.hookChanges,
|
|
1052
|
-
...o.includeState !== false ? {
|
|
1053
|
-
hookState,
|
|
1054
|
-
contexts: trackHooks ? snapshotContexts(fiber) : void 0,
|
|
1055
|
-
state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
|
|
1056
|
-
} : {},
|
|
1057
|
-
updaters,
|
|
1058
|
-
commitCause,
|
|
1059
|
-
afterCommit,
|
|
1060
|
-
key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
|
|
1061
|
-
parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
|
|
1062
|
-
owner: ownerName(fiber),
|
|
1063
|
-
path: componentPath(fiber),
|
|
1064
|
-
memoized: isMemoizedFiber(fiber),
|
|
1065
|
-
selfDuration: durations ? durations.self : void 0,
|
|
1066
|
-
treeDuration: durations ? durations.tree : void 0,
|
|
1067
|
-
commitId,
|
|
1068
|
-
commitPriority,
|
|
1069
|
-
source: sourceOf(fiber)
|
|
1070
|
-
});
|
|
1071
|
-
dispatch(report);
|
|
1224
|
+
} finally {
|
|
1225
|
+
endDiffScope();
|
|
1072
1226
|
}
|
|
1073
1227
|
}
|
|
1074
1228
|
|
|
1229
|
+
// src/causes.ts
|
|
1230
|
+
var isAncestorReport = (anc, r) => anc.path.length < r.path.length && r.path[anc.path.length] === anc.component && anc.path.every((p, i) => r.path[i] === p);
|
|
1231
|
+
var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
|
|
1232
|
+
function indexByComponent(reports) {
|
|
1233
|
+
const index = /* @__PURE__ */ new Map();
|
|
1234
|
+
for (const r of reports) {
|
|
1235
|
+
const list = index.get(r.component);
|
|
1236
|
+
if (list) list.push(r);
|
|
1237
|
+
else index.set(r.component, [r]);
|
|
1238
|
+
}
|
|
1239
|
+
return index;
|
|
1240
|
+
}
|
|
1241
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
1242
|
+
let cur = r;
|
|
1243
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
1244
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
1245
|
+
const parent = cur.parent;
|
|
1246
|
+
const candidates = index.get(parent.name);
|
|
1247
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
1248
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
1249
|
+
seen.add(p);
|
|
1250
|
+
cur = p;
|
|
1251
|
+
}
|
|
1252
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
1253
|
+
}
|
|
1254
|
+
function rootCausesOf(reports) {
|
|
1255
|
+
const roots = /* @__PURE__ */ new Map();
|
|
1256
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
1257
|
+
const index = indexByComponent(reports);
|
|
1258
|
+
let avoidable = 0;
|
|
1259
|
+
let wasted = 0;
|
|
1260
|
+
for (const r of reports) {
|
|
1261
|
+
if (!r.avoidable) continue;
|
|
1262
|
+
avoidable++;
|
|
1263
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
1264
|
+
const root = rootCauseOf(r, reports, index);
|
|
1265
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
1266
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
1267
|
+
rootByReport.set(r, name);
|
|
1268
|
+
let agg = roots.get(name);
|
|
1269
|
+
if (!agg) {
|
|
1270
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1271
|
+
roots.set(name, agg);
|
|
1272
|
+
}
|
|
1273
|
+
agg.count++;
|
|
1274
|
+
bump(agg.components, r.component);
|
|
1275
|
+
}
|
|
1276
|
+
return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
|
|
1277
|
+
}
|
|
1278
|
+
function groupByCommit(reports) {
|
|
1279
|
+
const out = /* @__PURE__ */ new Map();
|
|
1280
|
+
for (const r of reports) {
|
|
1281
|
+
if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
|
|
1282
|
+
const list = out.get(r.commitId);
|
|
1283
|
+
if (list) list.push(r);
|
|
1284
|
+
else out.set(r.commitId, [r]);
|
|
1285
|
+
}
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
function rankRootCauses(reports) {
|
|
1289
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1290
|
+
for (const commit of groupByCommit(reports).values()) {
|
|
1291
|
+
for (const root of rootCausesOf(commit).roots) {
|
|
1292
|
+
let agg = byName.get(root.name);
|
|
1293
|
+
if (!agg) {
|
|
1294
|
+
agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1295
|
+
byName.set(root.name, agg);
|
|
1296
|
+
}
|
|
1297
|
+
agg.commits++;
|
|
1298
|
+
agg.count += root.count;
|
|
1299
|
+
for (const [c, n] of root.components) bump(agg.components, c, n);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
1303
|
+
}
|
|
1304
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1305
|
+
function formatRootCauses(reports, limit = 5) {
|
|
1306
|
+
const ranked = rankRootCauses(reports);
|
|
1307
|
+
const lines = ranked.slice(0, limit).map((root, i) => `${String(i + 1).padStart(2)}. <${root.name}> (${root.trigger}) started ${plural(root.commits, "commit")}, ${plural(root.count, "avoidable re-render")} (${[...root.components].map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
|
|
1308
|
+
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1309
|
+
return lines.join("\n");
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1075
1312
|
// src/fixes.ts
|
|
1076
|
-
var
|
|
1313
|
+
var MAX_FIX_REPORTS = 50;
|
|
1077
1314
|
var rootOf = (path) => path.split(/[.[]/)[0] || path;
|
|
1315
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
1316
|
+
function shortValue(v, max = 60) {
|
|
1317
|
+
let s;
|
|
1318
|
+
try {
|
|
1319
|
+
s = JSON.stringify(v);
|
|
1320
|
+
} catch {
|
|
1321
|
+
s = String(v);
|
|
1322
|
+
}
|
|
1323
|
+
if (s === void 0) s = String(v);
|
|
1324
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
1325
|
+
}
|
|
1078
1326
|
function fixesFor(r) {
|
|
1079
1327
|
const out = [];
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1328
|
+
const ownerName2 = r.owner || r.parent && r.parent.name || null;
|
|
1329
|
+
const propChanges = r.propChanges || [];
|
|
1330
|
+
const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
|
|
1082
1331
|
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
1332
|
+
const identical = changes.length === 0;
|
|
1083
1333
|
out.push({
|
|
1084
1334
|
kind: "memo",
|
|
1085
1335
|
owner: r.component,
|
|
1086
1336
|
target: r.component,
|
|
1087
1337
|
prop: null,
|
|
1088
1338
|
label: `Wrap <${r.component}> in React.memo`,
|
|
1089
|
-
detail:
|
|
1339
|
+
detail: identical ? `<${r.component}> re-rendered with identical props because <${r.parent && r.parent.name || "its parent"}> re-rendered.` : `<${r.component}> is not memoized: fixing its props alone will not stop the re-render.`,
|
|
1340
|
+
snippet: `// ${r.component}
|
|
1341
|
+
import { memo } from 'react';
|
|
1342
|
+
|
|
1343
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
1344
|
+
// ...
|
|
1345
|
+
});
|
|
1346
|
+
// class components: extend PureComponent instead`
|
|
1090
1347
|
});
|
|
1091
1348
|
}
|
|
1092
|
-
for (const c of
|
|
1093
|
-
if (!
|
|
1349
|
+
for (const c of propChanges) {
|
|
1350
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1351
|
+
const owner = ownerName2 || "?";
|
|
1094
1352
|
const root = rootOf(c.path);
|
|
1353
|
+
const id = identifier(root);
|
|
1095
1354
|
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
1096
|
-
out.push({
|
|
1355
|
+
out.push({
|
|
1356
|
+
kind: "children",
|
|
1357
|
+
owner,
|
|
1358
|
+
target: r.component,
|
|
1359
|
+
prop: "children",
|
|
1360
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
1361
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
1362
|
+
snippet: `// ${owner}
|
|
1363
|
+
import { useMemo } from 'react';
|
|
1364
|
+
|
|
1365
|
+
const children = useMemo(() => (
|
|
1366
|
+
<>{/* the same elements */}</>
|
|
1367
|
+
), [/* deps */]);
|
|
1368
|
+
|
|
1369
|
+
<${r.component}>{children}</${r.component}>
|
|
1370
|
+
|
|
1371
|
+
// static children: hoist them to module scope
|
|
1372
|
+
const STATIC = <em>hi</em>;`
|
|
1373
|
+
});
|
|
1097
1374
|
} else if (c.kind === "function") {
|
|
1098
|
-
out.push({
|
|
1375
|
+
out.push({
|
|
1376
|
+
kind: "useCallback",
|
|
1377
|
+
owner,
|
|
1378
|
+
target: r.component,
|
|
1379
|
+
prop: root,
|
|
1380
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
1381
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
1382
|
+
snippet: `// ${owner}
|
|
1383
|
+
import { useCallback } from 'react';
|
|
1384
|
+
|
|
1385
|
+
const ${id} = useCallback((/* args */) => {
|
|
1386
|
+
// ...
|
|
1387
|
+
}, [/* deps */]);
|
|
1388
|
+
|
|
1389
|
+
<${r.component} ${root}={${id}} />`
|
|
1390
|
+
});
|
|
1099
1391
|
} else if (c.kind === "element") {
|
|
1100
|
-
out.push({
|
|
1392
|
+
out.push({
|
|
1393
|
+
kind: "useMemoElement",
|
|
1394
|
+
owner,
|
|
1395
|
+
target: r.component,
|
|
1396
|
+
prop: root,
|
|
1397
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
1398
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
1399
|
+
snippet: `// ${owner}
|
|
1400
|
+
import { useMemo } from 'react';
|
|
1401
|
+
|
|
1402
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
1403
|
+
// or pass it as children from a component that does not re-render`
|
|
1404
|
+
});
|
|
1101
1405
|
} else {
|
|
1102
|
-
|
|
1406
|
+
const isArray = Array.isArray(c.next);
|
|
1407
|
+
out.push({
|
|
1408
|
+
kind: "useMemo",
|
|
1409
|
+
owner,
|
|
1410
|
+
target: r.component,
|
|
1411
|
+
prop: root,
|
|
1412
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
1413
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
1414
|
+
snippet: `// ${owner}
|
|
1415
|
+
import { useMemo } from 'react';
|
|
1416
|
+
|
|
1417
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
1418
|
+
|
|
1419
|
+
// or, when it never changes, hoist it to module scope:
|
|
1420
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
1421
|
+
});
|
|
1103
1422
|
}
|
|
1104
1423
|
}
|
|
1105
|
-
for (const c of [...r.stateChanges, ...r.hookChanges]) {
|
|
1106
|
-
const isContext = c.hook === "useContext";
|
|
1424
|
+
for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
|
|
1425
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
1107
1426
|
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
1108
1427
|
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
1109
1428
|
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
@@ -1113,20 +1432,63 @@ function fixesFor(r) {
|
|
|
1113
1432
|
target: r.component,
|
|
1114
1433
|
prop: ctxName,
|
|
1115
1434
|
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
1116
|
-
detail: `${c.changedKeys.
|
|
1435
|
+
detail: `${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer (like <${r.component}>) re-rendered. Consumers that read the other keys re-render for nothing.`,
|
|
1436
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
1437
|
+
// one context per independently-changing slice
|
|
1438
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
1439
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
1440
|
+
|
|
1441
|
+
// or keep one context and let consumers select a slice:
|
|
1442
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
1117
1443
|
});
|
|
1118
1444
|
continue;
|
|
1119
1445
|
}
|
|
1120
|
-
if (!
|
|
1446
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1121
1447
|
if (isContext) {
|
|
1122
|
-
out.push({
|
|
1448
|
+
out.push({
|
|
1449
|
+
kind: "contextValue",
|
|
1450
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
1451
|
+
target: r.component,
|
|
1452
|
+
prop: ctxName,
|
|
1453
|
+
label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
1454
|
+
detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
1455
|
+
snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
|
|
1456
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
1457
|
+
<${ctxName}.Provider value={value}>`
|
|
1458
|
+
});
|
|
1123
1459
|
} else if (c.hook === "useSyncExternalStore") {
|
|
1124
1460
|
const chain = c.custom || [];
|
|
1125
1461
|
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
1126
1462
|
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
1127
|
-
|
|
1463
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
1464
|
+
out.push({
|
|
1465
|
+
kind: "storeSnapshot",
|
|
1466
|
+
owner: r.component,
|
|
1467
|
+
target: r.component,
|
|
1468
|
+
prop: c.path,
|
|
1469
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
1470
|
+
detail: redux ? `the selector${via} returns a new object on every call, so the component re-renders on every store change.` : zustand ? `the store selector${via} returns a new object on every call, so the component re-renders on every store change.` : `${c.path}${via} returned a new reference with the same contents; getSnapshot must return a cached value.`,
|
|
1471
|
+
snippet: redux ? `// ${r.component}
|
|
1472
|
+
import { shallowEqual } from 'react-redux';
|
|
1473
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
1474
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
1475
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
1476
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
1477
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
1478
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
1479
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
1480
|
+
});
|
|
1128
1481
|
} else {
|
|
1129
|
-
out.push({
|
|
1482
|
+
out.push({
|
|
1483
|
+
kind: "bailout",
|
|
1484
|
+
owner: r.component,
|
|
1485
|
+
target: r.component,
|
|
1486
|
+
prop: c.path,
|
|
1487
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
1488
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
1489
|
+
snippet: `// ${r.component}
|
|
1490
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
1491
|
+
});
|
|
1130
1492
|
}
|
|
1131
1493
|
}
|
|
1132
1494
|
return out;
|
|
@@ -1140,11 +1502,12 @@ function rankFixes(reports) {
|
|
|
1140
1502
|
const key = fixKey(f);
|
|
1141
1503
|
let agg = byKey.get(key);
|
|
1142
1504
|
if (!agg) {
|
|
1143
|
-
agg = { ...f, key, count: 0, components: {} };
|
|
1505
|
+
agg = { ...f, key, count: 0, components: {}, reports: [] };
|
|
1144
1506
|
byKey.set(key, agg);
|
|
1145
1507
|
}
|
|
1146
1508
|
agg.count++;
|
|
1147
1509
|
agg.components[r.component] = (agg.components[r.component] || 0) + 1;
|
|
1510
|
+
if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
|
|
1148
1511
|
}
|
|
1149
1512
|
}
|
|
1150
1513
|
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
@@ -1154,6 +1517,8 @@ function formatFixes(reports, limit = 10) {
|
|
|
1154
1517
|
if (!ranked.length) return "No avoidable re-renders.";
|
|
1155
1518
|
const lines = ranked.slice(0, limit).map((f, i) => `${String(i + 1).padStart(2)}. ${f.label} (removes ${f.count}: ${Object.entries(f.components).map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
|
|
1156
1519
|
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1520
|
+
const roots = formatRootCauses(reports, 5);
|
|
1521
|
+
if (roots) lines.push("", "Root causes:", roots);
|
|
1157
1522
|
return lines.join("\n");
|
|
1158
1523
|
}
|
|
1159
1524
|
|
|
@@ -1252,7 +1617,7 @@ ${formatFixes(reports)}`);
|
|
|
1252
1617
|
};
|
|
1253
1618
|
}
|
|
1254
1619
|
|
|
1255
|
-
// src/
|
|
1620
|
+
// src/runner-report.ts
|
|
1256
1621
|
var OUT_ENV = "RERENDER_LENS_OUT";
|
|
1257
1622
|
function setupRerenderLens(options = {}, hooks = {}) {
|
|
1258
1623
|
ensureDevtoolsHook();
|
|
@@ -1287,49 +1652,55 @@ function slim(r) {
|
|
|
1287
1652
|
state: void 0
|
|
1288
1653
|
};
|
|
1289
1654
|
}
|
|
1290
|
-
|
|
1655
|
+
function createRunFile() {
|
|
1656
|
+
const dir = join(tmpdir(), "rerender-lens");
|
|
1657
|
+
mkdirSync(dir, { recursive: true });
|
|
1658
|
+
const file = join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1659
|
+
writeFileSync(file, "");
|
|
1660
|
+
process.env[OUT_ENV] = file;
|
|
1661
|
+
return file;
|
|
1662
|
+
}
|
|
1663
|
+
function finishRun(file, options = {}) {
|
|
1664
|
+
const reports = readRun(file);
|
|
1665
|
+
const log = options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1666
|
+
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1667
|
+
let error = null;
|
|
1668
|
+
log("");
|
|
1669
|
+
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1670
|
+
if (avoidable) log(formatFixes(reports, options.limit ?? 10));
|
|
1671
|
+
if (options.exportTo) writeFileSync(options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1672
|
+
if (options.budget) {
|
|
1673
|
+
const budget = JSON.parse(readFileSync(options.budget, "utf8"));
|
|
1674
|
+
const result = checkBudget(reports, budget);
|
|
1675
|
+
if (!result.ok) {
|
|
1676
|
+
const lines = result.violations.map((v) => ` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1677
|
+
for (const line of lines) log(line);
|
|
1678
|
+
error = new Error(`rerender-lens: re-render budget exceeded (${options.budget})
|
|
1679
|
+
${lines.join("\n")}`);
|
|
1680
|
+
} else log(` within budget (${options.budget})`);
|
|
1681
|
+
}
|
|
1682
|
+
try {
|
|
1683
|
+
rmSync(file, { force: true });
|
|
1684
|
+
} catch {
|
|
1685
|
+
}
|
|
1686
|
+
if (process.env[OUT_ENV] === file) delete process.env[OUT_ENV];
|
|
1687
|
+
return { reports, error };
|
|
1688
|
+
}
|
|
1689
|
+
var RunReporterCore = class {
|
|
1291
1690
|
constructor(options = {}) {
|
|
1292
1691
|
this.done = false;
|
|
1293
1692
|
this.options = options;
|
|
1294
|
-
|
|
1295
|
-
mkdirSync(dir, { recursive: true });
|
|
1296
|
-
this.file = join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1297
|
-
writeFileSync(this.file, "");
|
|
1298
|
-
process.env[OUT_ENV] = this.file;
|
|
1693
|
+
this.file = createRunFile();
|
|
1299
1694
|
}
|
|
1300
|
-
|
|
1695
|
+
/** Publish the path again (the runner may have replaced `process.env` since the constructor ran). */
|
|
1696
|
+
arm() {
|
|
1301
1697
|
process.env[OUT_ENV] = this.file;
|
|
1302
1698
|
}
|
|
1303
|
-
|
|
1304
|
-
this.finish();
|
|
1305
|
-
}
|
|
1306
|
-
/** Vitest < 3 name for the same event. */
|
|
1307
|
-
onFinished() {
|
|
1308
|
-
this.finish();
|
|
1309
|
-
}
|
|
1699
|
+
/** The run's result, or null when the run was already finished (runners fire two "done" events). */
|
|
1310
1700
|
finish() {
|
|
1311
|
-
if (this.done) return;
|
|
1701
|
+
if (this.done) return null;
|
|
1312
1702
|
this.done = true;
|
|
1313
|
-
|
|
1314
|
-
const log = this.options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1315
|
-
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1316
|
-
log("");
|
|
1317
|
-
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1318
|
-
if (avoidable) log(formatFixes(reports, this.options.limit ?? 10));
|
|
1319
|
-
if (this.options.exportTo) writeFileSync(this.options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1320
|
-
if (this.options.budget) {
|
|
1321
|
-
const budget = JSON.parse(readFileSync(this.options.budget, "utf8"));
|
|
1322
|
-
const result = checkBudget(reports, budget);
|
|
1323
|
-
if (!result.ok) {
|
|
1324
|
-
for (const v of result.violations) log(` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1325
|
-
process.exitCode = 1;
|
|
1326
|
-
} else log(` within budget (${this.options.budget})`);
|
|
1327
|
-
}
|
|
1328
|
-
try {
|
|
1329
|
-
rmSync(this.file, { force: true });
|
|
1330
|
-
} catch {
|
|
1331
|
-
}
|
|
1332
|
-
if (process.env[OUT_ENV] === this.file) delete process.env[OUT_ENV];
|
|
1703
|
+
return finishRun(this.file, this.options);
|
|
1333
1704
|
}
|
|
1334
1705
|
};
|
|
1335
1706
|
function readRun(file) {
|
|
@@ -1346,6 +1717,27 @@ function readRun(file) {
|
|
|
1346
1717
|
return out;
|
|
1347
1718
|
}
|
|
1348
1719
|
|
|
1720
|
+
// src/vitest.ts
|
|
1721
|
+
var RerenderLensReporter = class {
|
|
1722
|
+
constructor(options = {}) {
|
|
1723
|
+
this.core = new RunReporterCore(options);
|
|
1724
|
+
}
|
|
1725
|
+
onInit() {
|
|
1726
|
+
this.core.arm();
|
|
1727
|
+
}
|
|
1728
|
+
onTestRunEnd() {
|
|
1729
|
+
this.finish();
|
|
1730
|
+
}
|
|
1731
|
+
/** Vitest < 3 name for the same event. */
|
|
1732
|
+
onFinished() {
|
|
1733
|
+
this.finish();
|
|
1734
|
+
}
|
|
1735
|
+
finish() {
|
|
1736
|
+
const result = this.core.finish();
|
|
1737
|
+
if (result?.error) process.exitCode = 1;
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1349
1741
|
export { OUT_ENV, checkBudget, RerenderLensReporter as default, formatFixes, readRun, setupRerenderLens };
|
|
1350
1742
|
//# sourceMappingURL=vitest.js.map
|
|
1351
1743
|
//# sourceMappingURL=vitest.js.map
|