rerender-lens 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +192 -362
- package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
- package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
- package/dist/cli.cjs +248 -31
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +248 -31
- package/dist/cli.js.map +1 -1
- package/dist/{devtools-CZebzpF6.d.ts → devtools-BYWat7nQ.d.ts} +7 -1
- package/dist/{devtools-BkCct3cJ.d.cts → devtools-DJi_6AfJ.d.cts} +7 -1
- package/dist/index.cjs +439 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -8
- package/dist/index.d.ts +131 -8
- package/dist/index.js +424 -42
- package/dist/index.js.map +1 -1
- package/dist/jest-setup.cjs +1673 -0
- package/dist/jest-setup.cjs.map +1 -0
- package/dist/jest-setup.d.cts +8 -0
- package/dist/jest-setup.d.ts +8 -0
- package/dist/jest-setup.js +1671 -0
- package/dist/jest-setup.js.map +1 -0
- package/dist/jest.cjs +1760 -0
- package/dist/jest.cjs.map +1 -0
- package/dist/jest.d.cts +23 -0
- package/dist/jest.d.ts +23 -0
- package/dist/jest.js +1751 -0
- package/dist/jest.js.map +1 -0
- package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
- package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.d.ts} +8 -4
- package/dist/playwright.cjs +230 -28
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +4 -4
- package/dist/playwright.d.ts +4 -4
- package/dist/playwright.js +230 -28
- package/dist/playwright.js.map +1 -1
- package/dist/relay.cjs +10 -2
- package/dist/relay.cjs.map +1 -1
- package/dist/relay.js +10 -2
- package/dist/relay.js.map +1 -1
- package/dist/rerender-lens.iife.js +356 -54
- package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
- package/dist/runner-report-CW8X5RwK.d.ts +46 -0
- package/dist/setup.cjs +112 -27
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +112 -27
- package/dist/setup.js.map +1 -1
- package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
- package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vitest-setup.cjs +337 -41
- package/dist/vitest-setup.cjs.map +1 -1
- package/dist/vitest-setup.d.cts +4 -4
- package/dist/vitest-setup.d.ts +4 -4
- package/dist/vitest-setup.js +337 -41
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +400 -77
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +21 -39
- package/dist/vitest.d.ts +21 -39
- package/dist/vitest.js +401 -78
- package/dist/vitest.js.map +1 -1
- package/package.json +40 -4
- package/panel/panel.css +1 -0
- package/panel/panel.js +499 -309
package/dist/vitest-setup.cjs
CHANGED
|
@@ -129,6 +129,7 @@ function deepEqualObjects(a, b, seen) {
|
|
|
129
129
|
}
|
|
130
130
|
return false;
|
|
131
131
|
}
|
|
132
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
132
133
|
function classify(prev, next) {
|
|
133
134
|
if (typeof prev === "function" && typeof next === "function") {
|
|
134
135
|
return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
|
|
@@ -165,30 +166,30 @@ function diffRecords(prev, next, basePath = "") {
|
|
|
165
166
|
if (Object.is(a, b)) continue;
|
|
166
167
|
const kind = classify(a, b);
|
|
167
168
|
if (kind === "different") {
|
|
168
|
-
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path), kind, prev: a, next: b });
|
|
169
|
+
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
|
|
169
170
|
} else {
|
|
170
171
|
changes.push({ path, kind, prev: a, next: b });
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
return changes;
|
|
174
175
|
}
|
|
175
|
-
function firstDifferentPath(a, b, path, depth = 0) {
|
|
176
|
-
if (depth > 8) return path;
|
|
176
|
+
function firstDifferentPath(a, b, path = "", depth = 0) {
|
|
177
|
+
if (depth > 8) return path || "(value)";
|
|
177
178
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
178
|
-
if (a.length !== b.length) return `${path}.length
|
|
179
|
+
if (a.length !== b.length) return path ? `${path}.length` : "length";
|
|
179
180
|
for (let i = 0; i < a.length; i++) {
|
|
180
181
|
if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
|
|
181
182
|
}
|
|
182
|
-
return
|
|
183
|
+
return null;
|
|
183
184
|
}
|
|
184
185
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
185
186
|
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
186
187
|
for (const k of keys) {
|
|
187
188
|
if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
|
|
188
189
|
}
|
|
189
|
-
return
|
|
190
|
+
return null;
|
|
190
191
|
}
|
|
191
|
-
return path;
|
|
192
|
+
return deepEqual(a, b) ? null : path || "(value)";
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
// src/report.ts
|
|
@@ -206,10 +207,12 @@ function storeAdvice(custom) {
|
|
|
206
207
|
}
|
|
207
208
|
var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
208
209
|
var isChildren = (change) => change.path === "children";
|
|
210
|
+
var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
|
|
209
211
|
function fixFor(change) {
|
|
210
212
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
211
213
|
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`;
|
|
212
214
|
}
|
|
215
|
+
if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
|
|
213
216
|
switch (change.kind) {
|
|
214
217
|
case "function":
|
|
215
218
|
return `wrap it in useCallback (or hoist it out of the parent's render)`;
|
|
@@ -225,6 +228,7 @@ function describe(change) {
|
|
|
225
228
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
226
229
|
return `children are new React elements with the same types and props on every render of the parent`;
|
|
227
230
|
}
|
|
231
|
+
if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
|
|
228
232
|
switch (change.kind) {
|
|
229
233
|
case "deep-equal":
|
|
230
234
|
return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
|
|
@@ -252,11 +256,21 @@ function buildReport(input) {
|
|
|
252
256
|
if (causes.length === 0) trigger = "parent";
|
|
253
257
|
else if (causes.length === 1) trigger = causes[0];
|
|
254
258
|
else trigger = "mixed";
|
|
255
|
-
const
|
|
259
|
+
const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
|
|
260
|
+
const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
|
|
261
|
+
const revealed = input.revealed === true && trigger === "parent";
|
|
262
|
+
const avoidable = trigger === "parent" && !cacheHit && !revealed;
|
|
256
263
|
const reasons = [];
|
|
257
|
-
|
|
264
|
+
const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
|
|
265
|
+
if (cacheHit) {
|
|
266
|
+
reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
|
|
267
|
+
} else if (revealed) {
|
|
268
|
+
reasons.push(
|
|
269
|
+
`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.`
|
|
270
|
+
);
|
|
271
|
+
} else if (avoidable && nothingChanged) {
|
|
258
272
|
const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
|
|
259
|
-
reasons.push(`re-rendered with identical props because ${who}.
|
|
273
|
+
reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
|
|
260
274
|
} else if (avoidable && input.parent) {
|
|
261
275
|
reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
|
|
262
276
|
}
|
|
@@ -265,9 +279,9 @@ function buildReport(input) {
|
|
|
265
279
|
reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
|
|
266
280
|
}
|
|
267
281
|
const memoized = input.memoized !== false;
|
|
268
|
-
if (avoidable && !memoized && input.propChanges.length > 0) {
|
|
282
|
+
if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
|
|
269
283
|
reasons.push(
|
|
270
|
-
`"${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).`
|
|
284
|
+
`"${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)"}.`
|
|
271
285
|
);
|
|
272
286
|
}
|
|
273
287
|
for (const c of stateChanges) {
|
|
@@ -279,6 +293,8 @@ function buildReport(input) {
|
|
|
279
293
|
const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
|
|
280
294
|
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` : "";
|
|
281
295
|
reasons.push(`${c.path} changed${where}${keys}.`);
|
|
296
|
+
} else if (c.hook === "useDeferredValue" && isGenuine(c)) {
|
|
297
|
+
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.`);
|
|
282
298
|
} else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
|
|
283
299
|
else if (isStateHook(c))
|
|
284
300
|
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.`);
|
|
@@ -309,6 +325,7 @@ function buildReport(input) {
|
|
|
309
325
|
reasons,
|
|
310
326
|
time: now()
|
|
311
327
|
};
|
|
328
|
+
if (input.compiled) report.compiled = true;
|
|
312
329
|
if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
|
|
313
330
|
if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
|
|
314
331
|
if (input.commitPriority) report.commitPriority = input.commitPriority;
|
|
@@ -346,10 +363,10 @@ var KIND_LABEL = {
|
|
|
346
363
|
};
|
|
347
364
|
function summarize(report) {
|
|
348
365
|
const counts2 = /* @__PURE__ */ new Map();
|
|
349
|
-
for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
|
|
366
|
+
for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
|
|
350
367
|
counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
|
|
351
368
|
}
|
|
352
|
-
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
|
|
369
|
+
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
|
|
353
370
|
if (parts.length === 0) parts.push("no changes");
|
|
354
371
|
return parts.join(", ");
|
|
355
372
|
}
|
|
@@ -473,6 +490,27 @@ function disable() {
|
|
|
473
490
|
}
|
|
474
491
|
|
|
475
492
|
// src/hookNames.ts
|
|
493
|
+
var NODE_COUNT = {
|
|
494
|
+
useState: 1,
|
|
495
|
+
useReducer: 1,
|
|
496
|
+
useRef: 1,
|
|
497
|
+
useMemo: 1,
|
|
498
|
+
useCallback: 1,
|
|
499
|
+
useEffect: 1,
|
|
500
|
+
useLayoutEffect: 1,
|
|
501
|
+
useInsertionEffect: 1,
|
|
502
|
+
useImperativeHandle: 1,
|
|
503
|
+
useSyncExternalStore: 2,
|
|
504
|
+
useTransition: 2,
|
|
505
|
+
useDeferredValue: 1,
|
|
506
|
+
useId: 1,
|
|
507
|
+
useOptimistic: 1,
|
|
508
|
+
useActionState: 3,
|
|
509
|
+
useFormState: 3,
|
|
510
|
+
useEffectEvent: 1,
|
|
511
|
+
useCacheRefresh: 1
|
|
512
|
+
};
|
|
513
|
+
var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
|
|
476
514
|
var noop = () => {
|
|
477
515
|
};
|
|
478
516
|
function customHooksFromStack(stack, componentName, marker) {
|
|
@@ -487,7 +525,7 @@ function customHooksFromStack(stack, componentName, marker) {
|
|
|
487
525
|
continue;
|
|
488
526
|
}
|
|
489
527
|
if (name === componentName || name.endsWith("." + componentName)) break;
|
|
490
|
-
if (/^use[A-Z0-9_]/.test(name)) names.push(name);
|
|
528
|
+
if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
|
|
491
529
|
if (names.length > 8) break;
|
|
492
530
|
}
|
|
493
531
|
return names;
|
|
@@ -771,6 +809,11 @@ function isMemoizedFiber(fiber) {
|
|
|
771
809
|
}
|
|
772
810
|
return false;
|
|
773
811
|
}
|
|
812
|
+
function isCompiledFiber(fiber) {
|
|
813
|
+
if (fiber.tag === ClassComponent) return false;
|
|
814
|
+
const cache2 = fiber.updateQueue?.memoCache;
|
|
815
|
+
return !!cache2 && Array.isArray(cache2.data);
|
|
816
|
+
}
|
|
774
817
|
function durationsOf(fiber) {
|
|
775
818
|
if (typeof fiber.actualDuration !== "number") return null;
|
|
776
819
|
const tree = fiber.actualDuration;
|
|
@@ -923,7 +966,8 @@ function diffHooks(fiber, alt) {
|
|
|
923
966
|
const labels = hookLabelsFor(fiber, b);
|
|
924
967
|
let i = 0;
|
|
925
968
|
while (a && b) {
|
|
926
|
-
|
|
969
|
+
const deferred = labels?.[i] === "useDeferredValue";
|
|
970
|
+
if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
|
|
927
971
|
const hook = labels?.[i] ?? hookLabel2(b);
|
|
928
972
|
out.push({
|
|
929
973
|
path: `${hook}#${i}`,
|
|
@@ -957,6 +1001,10 @@ function diffContexts(fiber, alt) {
|
|
|
957
1001
|
let a = alt.dependencies?.firstContext ?? null;
|
|
958
1002
|
let b = fiber.dependencies?.firstContext ?? null;
|
|
959
1003
|
let i = 0;
|
|
1004
|
+
if (b && !("memoizedValue" in b)) {
|
|
1005
|
+
warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
960
1008
|
while (a && b) {
|
|
961
1009
|
if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
|
|
962
1010
|
const name = b.context.displayName ?? "Context";
|
|
@@ -989,10 +1037,26 @@ function diffContexts(fiber, alt) {
|
|
|
989
1037
|
return out;
|
|
990
1038
|
}
|
|
991
1039
|
var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
1040
|
+
var isRefObject2 = (v) => {
|
|
1041
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1042
|
+
const keys = Object.keys(v);
|
|
1043
|
+
return keys.length === 1 && keys[0] === "current";
|
|
1044
|
+
};
|
|
1045
|
+
function diffProps(prev, next) {
|
|
1046
|
+
if (prev === next) return [];
|
|
1047
|
+
const pr = prev.ref;
|
|
1048
|
+
const nr = next.ref;
|
|
1049
|
+
if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
|
|
1050
|
+
const { ref: _p, ...restPrev } = prev;
|
|
1051
|
+
const { ref: _n, ...restNext } = next;
|
|
1052
|
+
return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
|
|
1053
|
+
}
|
|
1054
|
+
return diffRecords(prev, next);
|
|
1055
|
+
}
|
|
992
1056
|
function analyze(fiber, alt, trackHooks) {
|
|
993
1057
|
const prevProps = alt.memoizedProps ?? {};
|
|
994
1058
|
const nextProps = fiber.memoizedProps ?? {};
|
|
995
|
-
const propChanges =
|
|
1059
|
+
const propChanges = diffProps(prevProps, nextProps);
|
|
996
1060
|
let stateChanges = [];
|
|
997
1061
|
let hookChanges = [];
|
|
998
1062
|
if (fiber.tag === ClassComponent) {
|
|
@@ -1005,7 +1069,7 @@ function analyze(fiber, alt, trackHooks) {
|
|
|
1005
1069
|
} else if (trackHooks) {
|
|
1006
1070
|
hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
|
|
1007
1071
|
}
|
|
1008
|
-
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
|
|
1072
|
+
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
|
|
1009
1073
|
const causes = [];
|
|
1010
1074
|
if (propChanges.some(isGenuine2)) causes.push("props");
|
|
1011
1075
|
if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
|
|
@@ -1045,6 +1109,24 @@ var lastCommit = null;
|
|
|
1045
1109
|
var EFFECT_LOOP_WINDOW_MS = 50;
|
|
1046
1110
|
var MAX_REPORTS_PER_COMMIT = 200;
|
|
1047
1111
|
var COMMIT_TIME_BUDGET_MS = 25;
|
|
1112
|
+
function isDeferredCatchUp(fiber) {
|
|
1113
|
+
const types = fiber._debugHookTypes;
|
|
1114
|
+
if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
|
|
1115
|
+
const first = fiber.memoizedState;
|
|
1116
|
+
if (!isHookList(first)) return false;
|
|
1117
|
+
const labels = hookLabelsFor(fiber, first);
|
|
1118
|
+
if (!labels) return false;
|
|
1119
|
+
let a = fiber.alternate.memoizedState;
|
|
1120
|
+
let b = first;
|
|
1121
|
+
for (let i = 0; a && b; a = a.next, b = b.next, i++) {
|
|
1122
|
+
if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
|
|
1123
|
+
}
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
function isUnder(fiber, boundaries) {
|
|
1127
|
+
for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1048
1130
|
function onCommit(root, commitPriority) {
|
|
1049
1131
|
const s = getState();
|
|
1050
1132
|
if (!s.enabled) return;
|
|
@@ -1054,7 +1136,7 @@ function onCommit(root, commitPriority) {
|
|
|
1054
1136
|
const rendered = [];
|
|
1055
1137
|
const renderedNames = /* @__PURE__ */ new Set();
|
|
1056
1138
|
let hot = false;
|
|
1057
|
-
let
|
|
1139
|
+
let resolvedBoundaries = null;
|
|
1058
1140
|
const stack = [root.current];
|
|
1059
1141
|
while (stack.length) {
|
|
1060
1142
|
const fiber = stack.pop();
|
|
@@ -1064,7 +1146,7 @@ function onCommit(root, commitPriority) {
|
|
|
1064
1146
|
renderedNames.add(fiberName(fiber));
|
|
1065
1147
|
if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
|
|
1066
1148
|
}
|
|
1067
|
-
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null)
|
|
1149
|
+
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
|
|
1068
1150
|
if (!alt || fiber.child !== alt.child) {
|
|
1069
1151
|
for (let c = fiber.child; c; c = c.sibling) stack.push(c);
|
|
1070
1152
|
}
|
|
@@ -1076,10 +1158,10 @@ function onCommit(root, commitPriority) {
|
|
|
1076
1158
|
let commitCause;
|
|
1077
1159
|
let afterCommit;
|
|
1078
1160
|
const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
|
|
1079
|
-
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
|
|
1161
|
+
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
|
|
1080
1162
|
commitCause = "effect-after-commit";
|
|
1081
1163
|
afterCommit = lastCommit.id;
|
|
1082
|
-
} else if (
|
|
1164
|
+
} else if (resolvedBoundaries) commitCause = "suspense-resolved";
|
|
1083
1165
|
if (rendered.length === 0) {
|
|
1084
1166
|
lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
|
|
1085
1167
|
return;
|
|
@@ -1131,6 +1213,9 @@ function onCommit(root, commitPriority) {
|
|
|
1131
1213
|
owner: ownerName(fiber),
|
|
1132
1214
|
path: componentPath(fiber),
|
|
1133
1215
|
memoized: isMemoizedFiber(fiber),
|
|
1216
|
+
compiled: isCompiledFiber(fiber),
|
|
1217
|
+
classComponent: fiber.tag === ClassComponent,
|
|
1218
|
+
revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
|
|
1134
1219
|
selfDuration: durations ? durations.self : void 0,
|
|
1135
1220
|
treeDuration: durations ? durations.tree : void 0,
|
|
1136
1221
|
commitId,
|
|
@@ -1144,38 +1229,203 @@ function onCommit(root, commitPriority) {
|
|
|
1144
1229
|
}
|
|
1145
1230
|
}
|
|
1146
1231
|
|
|
1232
|
+
// src/causes.ts
|
|
1233
|
+
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);
|
|
1234
|
+
var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
|
|
1235
|
+
function indexByComponent(reports) {
|
|
1236
|
+
const index = /* @__PURE__ */ new Map();
|
|
1237
|
+
for (const r of reports) {
|
|
1238
|
+
const list = index.get(r.component);
|
|
1239
|
+
if (list) list.push(r);
|
|
1240
|
+
else index.set(r.component, [r]);
|
|
1241
|
+
}
|
|
1242
|
+
return index;
|
|
1243
|
+
}
|
|
1244
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
1245
|
+
let cur = r;
|
|
1246
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
1247
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
1248
|
+
const parent = cur.parent;
|
|
1249
|
+
const candidates = index.get(parent.name);
|
|
1250
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
1251
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
1252
|
+
seen.add(p);
|
|
1253
|
+
cur = p;
|
|
1254
|
+
}
|
|
1255
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
1256
|
+
}
|
|
1257
|
+
function rootCausesOf(reports) {
|
|
1258
|
+
const roots = /* @__PURE__ */ new Map();
|
|
1259
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
1260
|
+
const index = indexByComponent(reports);
|
|
1261
|
+
let avoidable = 0;
|
|
1262
|
+
let wasted = 0;
|
|
1263
|
+
for (const r of reports) {
|
|
1264
|
+
if (!r.avoidable) continue;
|
|
1265
|
+
avoidable++;
|
|
1266
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
1267
|
+
const root = rootCauseOf(r, reports, index);
|
|
1268
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
1269
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
1270
|
+
rootByReport.set(r, name);
|
|
1271
|
+
let agg = roots.get(name);
|
|
1272
|
+
if (!agg) {
|
|
1273
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1274
|
+
roots.set(name, agg);
|
|
1275
|
+
}
|
|
1276
|
+
agg.count++;
|
|
1277
|
+
bump(agg.components, r.component);
|
|
1278
|
+
}
|
|
1279
|
+
return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
|
|
1280
|
+
}
|
|
1281
|
+
function groupByCommit(reports) {
|
|
1282
|
+
const out = /* @__PURE__ */ new Map();
|
|
1283
|
+
for (const r of reports) {
|
|
1284
|
+
if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
|
|
1285
|
+
const list = out.get(r.commitId);
|
|
1286
|
+
if (list) list.push(r);
|
|
1287
|
+
else out.set(r.commitId, [r]);
|
|
1288
|
+
}
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
function rankRootCauses(reports) {
|
|
1292
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1293
|
+
for (const commit of groupByCommit(reports).values()) {
|
|
1294
|
+
for (const root of rootCausesOf(commit).roots) {
|
|
1295
|
+
let agg = byName.get(root.name);
|
|
1296
|
+
if (!agg) {
|
|
1297
|
+
agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1298
|
+
byName.set(root.name, agg);
|
|
1299
|
+
}
|
|
1300
|
+
agg.commits++;
|
|
1301
|
+
agg.count += root.count;
|
|
1302
|
+
for (const [c, n] of root.components) bump(agg.components, c, n);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
1306
|
+
}
|
|
1307
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1308
|
+
function formatRootCauses(reports, limit = 5) {
|
|
1309
|
+
const ranked = rankRootCauses(reports);
|
|
1310
|
+
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(", ")})`);
|
|
1311
|
+
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1312
|
+
return lines.join("\n");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1147
1315
|
// src/fixes.ts
|
|
1148
|
-
var
|
|
1316
|
+
var MAX_FIX_REPORTS = 50;
|
|
1149
1317
|
var rootOf = (path) => path.split(/[.[]/)[0] || path;
|
|
1318
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
1319
|
+
function shortValue(v, max = 60) {
|
|
1320
|
+
let s;
|
|
1321
|
+
try {
|
|
1322
|
+
s = JSON.stringify(v);
|
|
1323
|
+
} catch {
|
|
1324
|
+
s = String(v);
|
|
1325
|
+
}
|
|
1326
|
+
if (s === void 0) s = String(v);
|
|
1327
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
1328
|
+
}
|
|
1150
1329
|
function fixesFor(r) {
|
|
1151
1330
|
const out = [];
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1331
|
+
const ownerName2 = r.owner || r.parent && r.parent.name || null;
|
|
1332
|
+
const propChanges = r.propChanges || [];
|
|
1333
|
+
const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
|
|
1154
1334
|
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
1335
|
+
const identical = changes.length === 0;
|
|
1155
1336
|
out.push({
|
|
1156
1337
|
kind: "memo",
|
|
1157
1338
|
owner: r.component,
|
|
1158
1339
|
target: r.component,
|
|
1159
1340
|
prop: null,
|
|
1160
1341
|
label: `Wrap <${r.component}> in React.memo`,
|
|
1161
|
-
detail:
|
|
1342
|
+
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.`,
|
|
1343
|
+
snippet: `// ${r.component}
|
|
1344
|
+
import { memo } from 'react';
|
|
1345
|
+
|
|
1346
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
1347
|
+
// ...
|
|
1348
|
+
});
|
|
1349
|
+
// class components: extend PureComponent instead`
|
|
1162
1350
|
});
|
|
1163
1351
|
}
|
|
1164
|
-
for (const c of
|
|
1165
|
-
if (!
|
|
1352
|
+
for (const c of propChanges) {
|
|
1353
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1354
|
+
const owner = ownerName2 || "?";
|
|
1166
1355
|
const root = rootOf(c.path);
|
|
1356
|
+
const id = identifier(root);
|
|
1167
1357
|
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
1168
|
-
out.push({
|
|
1358
|
+
out.push({
|
|
1359
|
+
kind: "children",
|
|
1360
|
+
owner,
|
|
1361
|
+
target: r.component,
|
|
1362
|
+
prop: "children",
|
|
1363
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
1364
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
1365
|
+
snippet: `// ${owner}
|
|
1366
|
+
import { useMemo } from 'react';
|
|
1367
|
+
|
|
1368
|
+
const children = useMemo(() => (
|
|
1369
|
+
<>{/* the same elements */}</>
|
|
1370
|
+
), [/* deps */]);
|
|
1371
|
+
|
|
1372
|
+
<${r.component}>{children}</${r.component}>
|
|
1373
|
+
|
|
1374
|
+
// static children: hoist them to module scope
|
|
1375
|
+
const STATIC = <em>hi</em>;`
|
|
1376
|
+
});
|
|
1169
1377
|
} else if (c.kind === "function") {
|
|
1170
|
-
out.push({
|
|
1378
|
+
out.push({
|
|
1379
|
+
kind: "useCallback",
|
|
1380
|
+
owner,
|
|
1381
|
+
target: r.component,
|
|
1382
|
+
prop: root,
|
|
1383
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
1384
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
1385
|
+
snippet: `// ${owner}
|
|
1386
|
+
import { useCallback } from 'react';
|
|
1387
|
+
|
|
1388
|
+
const ${id} = useCallback((/* args */) => {
|
|
1389
|
+
// ...
|
|
1390
|
+
}, [/* deps */]);
|
|
1391
|
+
|
|
1392
|
+
<${r.component} ${root}={${id}} />`
|
|
1393
|
+
});
|
|
1171
1394
|
} else if (c.kind === "element") {
|
|
1172
|
-
out.push({
|
|
1395
|
+
out.push({
|
|
1396
|
+
kind: "useMemoElement",
|
|
1397
|
+
owner,
|
|
1398
|
+
target: r.component,
|
|
1399
|
+
prop: root,
|
|
1400
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
1401
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
1402
|
+
snippet: `// ${owner}
|
|
1403
|
+
import { useMemo } from 'react';
|
|
1404
|
+
|
|
1405
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
1406
|
+
// or pass it as children from a component that does not re-render`
|
|
1407
|
+
});
|
|
1173
1408
|
} else {
|
|
1174
|
-
|
|
1409
|
+
const isArray = Array.isArray(c.next);
|
|
1410
|
+
out.push({
|
|
1411
|
+
kind: "useMemo",
|
|
1412
|
+
owner,
|
|
1413
|
+
target: r.component,
|
|
1414
|
+
prop: root,
|
|
1415
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
1416
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
1417
|
+
snippet: `// ${owner}
|
|
1418
|
+
import { useMemo } from 'react';
|
|
1419
|
+
|
|
1420
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
1421
|
+
|
|
1422
|
+
// or, when it never changes, hoist it to module scope:
|
|
1423
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
1424
|
+
});
|
|
1175
1425
|
}
|
|
1176
1426
|
}
|
|
1177
|
-
for (const c of [...r.stateChanges, ...r.hookChanges]) {
|
|
1178
|
-
const isContext = c.hook === "useContext";
|
|
1427
|
+
for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
|
|
1428
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
1179
1429
|
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
1180
1430
|
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
1181
1431
|
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
@@ -1185,20 +1435,63 @@ function fixesFor(r) {
|
|
|
1185
1435
|
target: r.component,
|
|
1186
1436
|
prop: ctxName,
|
|
1187
1437
|
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
1188
|
-
detail: `${c.changedKeys.
|
|
1438
|
+
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.`,
|
|
1439
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
1440
|
+
// one context per independently-changing slice
|
|
1441
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
1442
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
1443
|
+
|
|
1444
|
+
// or keep one context and let consumers select a slice:
|
|
1445
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
1189
1446
|
});
|
|
1190
1447
|
continue;
|
|
1191
1448
|
}
|
|
1192
|
-
if (!
|
|
1449
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1193
1450
|
if (isContext) {
|
|
1194
|
-
out.push({
|
|
1451
|
+
out.push({
|
|
1452
|
+
kind: "contextValue",
|
|
1453
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
1454
|
+
target: r.component,
|
|
1455
|
+
prop: ctxName,
|
|
1456
|
+
label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
1457
|
+
detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
1458
|
+
snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
|
|
1459
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
1460
|
+
<${ctxName}.Provider value={value}>`
|
|
1461
|
+
});
|
|
1195
1462
|
} else if (c.hook === "useSyncExternalStore") {
|
|
1196
1463
|
const chain = c.custom || [];
|
|
1197
1464
|
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
1198
1465
|
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
1199
|
-
|
|
1466
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
1467
|
+
out.push({
|
|
1468
|
+
kind: "storeSnapshot",
|
|
1469
|
+
owner: r.component,
|
|
1470
|
+
target: r.component,
|
|
1471
|
+
prop: c.path,
|
|
1472
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
1473
|
+
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.`,
|
|
1474
|
+
snippet: redux ? `// ${r.component}
|
|
1475
|
+
import { shallowEqual } from 'react-redux';
|
|
1476
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
1477
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
1478
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
1479
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
1480
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
1481
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
1482
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
1483
|
+
});
|
|
1200
1484
|
} else {
|
|
1201
|
-
out.push({
|
|
1485
|
+
out.push({
|
|
1486
|
+
kind: "bailout",
|
|
1487
|
+
owner: r.component,
|
|
1488
|
+
target: r.component,
|
|
1489
|
+
prop: c.path,
|
|
1490
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
1491
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
1492
|
+
snippet: `// ${r.component}
|
|
1493
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
1494
|
+
});
|
|
1202
1495
|
}
|
|
1203
1496
|
}
|
|
1204
1497
|
return out;
|
|
@@ -1212,11 +1505,12 @@ function rankFixes(reports) {
|
|
|
1212
1505
|
const key = fixKey(f);
|
|
1213
1506
|
let agg = byKey.get(key);
|
|
1214
1507
|
if (!agg) {
|
|
1215
|
-
agg = { ...f, key, count: 0, components: {} };
|
|
1508
|
+
agg = { ...f, key, count: 0, components: {}, reports: [] };
|
|
1216
1509
|
byKey.set(key, agg);
|
|
1217
1510
|
}
|
|
1218
1511
|
agg.count++;
|
|
1219
1512
|
agg.components[r.component] = (agg.components[r.component] || 0) + 1;
|
|
1513
|
+
if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
|
|
1220
1514
|
}
|
|
1221
1515
|
}
|
|
1222
1516
|
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
@@ -1226,6 +1520,8 @@ function formatFixes(reports, limit = 10) {
|
|
|
1226
1520
|
if (!ranked.length) return "No avoidable re-renders.";
|
|
1227
1521
|
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(", ")})`);
|
|
1228
1522
|
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1523
|
+
const roots = formatRootCauses(reports, 5);
|
|
1524
|
+
if (roots) lines.push("", "Root causes:", roots);
|
|
1229
1525
|
return lines.join("\n");
|
|
1230
1526
|
}
|
|
1231
1527
|
|
|
@@ -1324,7 +1620,7 @@ ${formatFixes(reports)}`);
|
|
|
1324
1620
|
};
|
|
1325
1621
|
}
|
|
1326
1622
|
|
|
1327
|
-
// src/
|
|
1623
|
+
// src/runner-report.ts
|
|
1328
1624
|
var OUT_ENV = "RERENDER_LENS_OUT";
|
|
1329
1625
|
function setupRerenderLens(options = {}, hooks = {}) {
|
|
1330
1626
|
ensureDevtoolsHook();
|