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