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.
Files changed (66) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +192 -362
  3. package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
  4. package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
  5. package/dist/cli.cjs +248 -31
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +248 -31
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-CZebzpF6.d.ts → devtools-BYWat7nQ.d.ts} +7 -1
  10. package/dist/{devtools-BkCct3cJ.d.cts → devtools-DJi_6AfJ.d.cts} +7 -1
  11. package/dist/index.cjs +439 -41
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +131 -8
  14. package/dist/index.d.ts +131 -8
  15. package/dist/index.js +424 -42
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1673 -0
  18. package/dist/jest-setup.cjs.map +1 -0
  19. package/dist/jest-setup.d.cts +8 -0
  20. package/dist/jest-setup.d.ts +8 -0
  21. package/dist/jest-setup.js +1671 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1760 -0
  24. package/dist/jest.cjs.map +1 -0
  25. package/dist/jest.d.cts +23 -0
  26. package/dist/jest.d.ts +23 -0
  27. package/dist/jest.js +1751 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.d.ts} +8 -4
  31. package/dist/playwright.cjs +230 -28
  32. package/dist/playwright.cjs.map +1 -1
  33. package/dist/playwright.d.cts +4 -4
  34. package/dist/playwright.d.ts +4 -4
  35. package/dist/playwright.js +230 -28
  36. package/dist/playwright.js.map +1 -1
  37. package/dist/relay.cjs +10 -2
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.js +10 -2
  40. package/dist/relay.js.map +1 -1
  41. package/dist/rerender-lens.iife.js +356 -54
  42. package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
  43. package/dist/runner-report-CW8X5RwK.d.ts +46 -0
  44. package/dist/setup.cjs +112 -27
  45. package/dist/setup.cjs.map +1 -1
  46. package/dist/setup.js +112 -27
  47. package/dist/setup.js.map +1 -1
  48. package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
  49. package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
  50. package/dist/vite.d.cts +1 -1
  51. package/dist/vite.d.ts +1 -1
  52. package/dist/vitest-setup.cjs +337 -41
  53. package/dist/vitest-setup.cjs.map +1 -1
  54. package/dist/vitest-setup.d.cts +4 -4
  55. package/dist/vitest-setup.d.ts +4 -4
  56. package/dist/vitest-setup.js +337 -41
  57. package/dist/vitest-setup.js.map +1 -1
  58. package/dist/vitest.cjs +400 -77
  59. package/dist/vitest.cjs.map +1 -1
  60. package/dist/vitest.d.cts +21 -39
  61. package/dist/vitest.d.ts +21 -39
  62. package/dist/vitest.js +401 -78
  63. package/dist/vitest.js.map +1 -1
  64. package/package.json +40 -4
  65. package/panel/panel.css +1 -0
  66. package/panel/panel.js +499 -309
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/vitest.ts
9
+ // src/runner-report.ts
10
10
 
11
11
  // src/diff.ts
12
12
  var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
@@ -130,6 +130,7 @@ function deepEqualObjects(a, b, seen) {
130
130
  }
131
131
  return false;
132
132
  }
133
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
133
134
  function classify(prev, next) {
134
135
  if (typeof prev === "function" && typeof next === "function") {
135
136
  return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
@@ -166,30 +167,30 @@ function diffRecords(prev, next, basePath = "") {
166
167
  if (Object.is(a, b)) continue;
167
168
  const kind = classify(a, b);
168
169
  if (kind === "different") {
169
- changes.push({ path: diffBudgetExhausted() ? 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 });
170
171
  } else {
171
172
  changes.push({ path, kind, prev: a, next: b });
172
173
  }
173
174
  }
174
175
  return changes;
175
176
  }
176
- function firstDifferentPath(a, b, path, depth = 0) {
177
- if (depth > 8) return path;
177
+ function firstDifferentPath(a, b, path = "", depth = 0) {
178
+ if (depth > 8) return path || "(value)";
178
179
  if (Array.isArray(a) && Array.isArray(b)) {
179
- if (a.length !== b.length) return `${path}.length`;
180
+ if (a.length !== b.length) return path ? `${path}.length` : "length";
180
181
  for (let i = 0; i < a.length; i++) {
181
182
  if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
182
183
  }
183
- return path;
184
+ return null;
184
185
  }
185
186
  if (isPlainObject(a) && isPlainObject(b)) {
186
187
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
187
188
  for (const k of keys) {
188
189
  if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
189
190
  }
190
- return path;
191
+ return null;
191
192
  }
192
- return path;
193
+ return deepEqual(a, b) ? null : path || "(value)";
193
194
  }
194
195
 
195
196
  // src/report.ts
@@ -207,10 +208,12 @@ function storeAdvice(custom) {
207
208
  }
208
209
  var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
209
210
  var isChildren = (change) => change.path === "children";
211
+ var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
210
212
  function fixFor(change) {
211
213
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
212
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`;
213
215
  }
216
+ if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
214
217
  switch (change.kind) {
215
218
  case "function":
216
219
  return `wrap it in useCallback (or hoist it out of the parent's render)`;
@@ -226,6 +229,7 @@ function describe(change) {
226
229
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
227
230
  return `children are new React elements with the same types and props on every render of the parent`;
228
231
  }
232
+ if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
229
233
  switch (change.kind) {
230
234
  case "deep-equal":
231
235
  return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
@@ -253,11 +257,21 @@ function buildReport(input) {
253
257
  if (causes.length === 0) trigger = "parent";
254
258
  else if (causes.length === 1) trigger = causes[0];
255
259
  else trigger = "mixed";
256
- const avoidable = trigger === "parent";
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;
257
264
  const reasons = [];
258
- if (avoidable && input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0) {
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) {
259
273
  const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
260
- reasons.push(`re-rendered with identical props because ${who}. Wrap "${input.component}" in React.memo (or extend PureComponent).`);
274
+ reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
261
275
  } else if (avoidable && input.parent) {
262
276
  reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
263
277
  }
@@ -266,9 +280,9 @@ function buildReport(input) {
266
280
  reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
267
281
  }
268
282
  const memoized = input.memoized !== false;
269
- if (avoidable && !memoized && input.propChanges.length > 0) {
283
+ if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
270
284
  reasons.push(
271
- `"${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)"}.`
272
286
  );
273
287
  }
274
288
  for (const c of stateChanges) {
@@ -280,6 +294,8 @@ function buildReport(input) {
280
294
  const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
281
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` : "";
282
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.`);
283
299
  } else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
284
300
  else if (isStateHook(c))
285
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.`);
@@ -310,6 +326,7 @@ function buildReport(input) {
310
326
  reasons,
311
327
  time: now()
312
328
  };
329
+ if (input.compiled) report.compiled = true;
313
330
  if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
314
331
  if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
315
332
  if (input.commitPriority) report.commitPriority = input.commitPriority;
@@ -347,10 +364,10 @@ var KIND_LABEL = {
347
364
  };
348
365
  function summarize(report) {
349
366
  const counts2 = /* @__PURE__ */ new Map();
350
- for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
367
+ for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
351
368
  counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
352
369
  }
353
- const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
370
+ const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
354
371
  if (parts.length === 0) parts.push("no changes");
355
372
  return parts.join(", ");
356
373
  }
@@ -474,6 +491,27 @@ function disable() {
474
491
  }
475
492
 
476
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"]);
477
515
  var noop = () => {
478
516
  };
479
517
  function customHooksFromStack(stack, componentName, marker) {
@@ -488,7 +526,7 @@ function customHooksFromStack(stack, componentName, marker) {
488
526
  continue;
489
527
  }
490
528
  if (name === componentName || name.endsWith("." + componentName)) break;
491
- 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);
492
530
  if (names.length > 8) break;
493
531
  }
494
532
  return names;
@@ -772,6 +810,11 @@ function isMemoizedFiber(fiber) {
772
810
  }
773
811
  return false;
774
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
+ }
775
818
  function durationsOf(fiber) {
776
819
  if (typeof fiber.actualDuration !== "number") return null;
777
820
  const tree = fiber.actualDuration;
@@ -924,7 +967,8 @@ function diffHooks(fiber, alt) {
924
967
  const labels = hookLabelsFor(fiber, b);
925
968
  let i = 0;
926
969
  while (a && b) {
927
- if ((isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
970
+ const deferred = labels?.[i] === "useDeferredValue";
971
+ if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
928
972
  const hook = labels?.[i] ?? hookLabel2(b);
929
973
  out.push({
930
974
  path: `${hook}#${i}`,
@@ -958,6 +1002,10 @@ function diffContexts(fiber, alt) {
958
1002
  let a = alt.dependencies?.firstContext ?? null;
959
1003
  let b = fiber.dependencies?.firstContext ?? null;
960
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
+ }
961
1009
  while (a && b) {
962
1010
  if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
963
1011
  const name = b.context.displayName ?? "Context";
@@ -990,10 +1038,26 @@ function diffContexts(fiber, alt) {
990
1038
  return out;
991
1039
  }
992
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
+ }
993
1057
  function analyze(fiber, alt, trackHooks) {
994
1058
  const prevProps = alt.memoizedProps ?? {};
995
1059
  const nextProps = fiber.memoizedProps ?? {};
996
- const propChanges = prevProps === nextProps ? [] : diffRecords(prevProps, nextProps);
1060
+ const propChanges = diffProps(prevProps, nextProps);
997
1061
  let stateChanges = [];
998
1062
  let hookChanges = [];
999
1063
  if (fiber.tag === ClassComponent) {
@@ -1006,7 +1070,7 @@ function analyze(fiber, alt, trackHooks) {
1006
1070
  } else if (trackHooks) {
1007
1071
  hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
1008
1072
  }
1009
- const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
1073
+ const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
1010
1074
  const causes = [];
1011
1075
  if (propChanges.some(isGenuine2)) causes.push("props");
1012
1076
  if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
@@ -1046,6 +1110,24 @@ var lastCommit = null;
1046
1110
  var EFFECT_LOOP_WINDOW_MS = 50;
1047
1111
  var MAX_REPORTS_PER_COMMIT = 200;
1048
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
+ }
1049
1131
  function onCommit(root, commitPriority) {
1050
1132
  const s = getState();
1051
1133
  if (!s.enabled) return;
@@ -1055,7 +1137,7 @@ function onCommit(root, commitPriority) {
1055
1137
  const rendered = [];
1056
1138
  const renderedNames = /* @__PURE__ */ new Set();
1057
1139
  let hot = false;
1058
- let suspenseResolved = false;
1140
+ let resolvedBoundaries = null;
1059
1141
  const stack = [root.current];
1060
1142
  while (stack.length) {
1061
1143
  const fiber = stack.pop();
@@ -1065,7 +1147,7 @@ function onCommit(root, commitPriority) {
1065
1147
  renderedNames.add(fiberName(fiber));
1066
1148
  if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
1067
1149
  }
1068
- if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) suspenseResolved = true;
1150
+ if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
1069
1151
  if (!alt || fiber.child !== alt.child) {
1070
1152
  for (let c = fiber.child; c; c = c.sibling) stack.push(c);
1071
1153
  }
@@ -1077,10 +1159,10 @@ function onCommit(root, commitPriority) {
1077
1159
  let commitCause;
1078
1160
  let afterCommit;
1079
1161
  const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
1080
- if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
1162
+ if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
1081
1163
  commitCause = "effect-after-commit";
1082
1164
  afterCommit = lastCommit.id;
1083
- } else if (suspenseResolved) commitCause = "suspense-resolved";
1165
+ } else if (resolvedBoundaries) commitCause = "suspense-resolved";
1084
1166
  if (rendered.length === 0) {
1085
1167
  lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
1086
1168
  return;
@@ -1132,6 +1214,9 @@ function onCommit(root, commitPriority) {
1132
1214
  owner: ownerName(fiber),
1133
1215
  path: componentPath(fiber),
1134
1216
  memoized: isMemoizedFiber(fiber),
1217
+ compiled: isCompiledFiber(fiber),
1218
+ classComponent: fiber.tag === ClassComponent,
1219
+ revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
1135
1220
  selfDuration: durations ? durations.self : void 0,
1136
1221
  treeDuration: durations ? durations.tree : void 0,
1137
1222
  commitId,
@@ -1145,38 +1230,203 @@ function onCommit(root, commitPriority) {
1145
1230
  }
1146
1231
  }
1147
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
+
1148
1316
  // src/fixes.ts
1149
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
1317
+ var MAX_FIX_REPORTS = 50;
1150
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
+ }
1151
1330
  function fixesFor(r) {
1152
1331
  const out = [];
1153
- const owner = r.owner || r.parent && r.parent.name || "?";
1154
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
1332
+ const ownerName2 = r.owner || r.parent && r.parent.name || null;
1333
+ const propChanges = r.propChanges || [];
1334
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
1155
1335
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
1336
+ const identical = changes.length === 0;
1156
1337
  out.push({
1157
1338
  kind: "memo",
1158
1339
  owner: r.component,
1159
1340
  target: r.component,
1160
1341
  prop: null,
1161
1342
  label: `Wrap <${r.component}> in React.memo`,
1162
- detail: changes.length === 0 ? `<${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
+ 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`
1163
1351
  });
1164
1352
  }
1165
- for (const c of r.propChanges) {
1166
- if (!AVOIDABLE.has(c.kind)) continue;
1353
+ for (const c of propChanges) {
1354
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1355
+ const owner = ownerName2 || "?";
1167
1356
  const root = rootOf(c.path);
1357
+ const id = identifier(root);
1168
1358
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
1169
- out.push({ kind: "children", owner, target: r.component, prop: "children", label: `memoize children of <${r.component}> in <${owner}>`, detail: `<${owner}> re-creates the children of <${r.component}> on every render.` });
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
+ });
1170
1378
  } else if (c.kind === "function") {
1171
- out.push({ kind: "useCallback", owner, target: r.component, prop: root, label: `useCallback(${root}) in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.` });
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
+ });
1172
1395
  } else if (c.kind === "element") {
1173
- out.push({ kind: "useMemoElement", owner, target: r.component, prop: root, label: `memoize element prop ${root} in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.` });
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
+ });
1174
1409
  } else {
1175
- out.push({ kind: "useMemo", owner, target: r.component, prop: root, label: `useMemo(${root}) in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new ${Array.isArray(c.next) ? "array" : "object"} with the same contents on every render of <${owner}>.` });
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
+ });
1176
1426
  }
1177
1427
  }
1178
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
1179
- const isContext = c.hook === "useContext";
1428
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
1429
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
1180
1430
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
1181
1431
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
1182
1432
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -1186,20 +1436,63 @@ function fixesFor(r) {
1186
1436
  target: r.component,
1187
1437
  prop: ctxName,
1188
1438
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
1189
- detail: `${c.changedKeys.length} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer re-rendered.`
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]});`
1190
1447
  });
1191
1448
  continue;
1192
1449
  }
1193
- if (!AVOIDABLE.has(c.kind)) continue;
1450
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1194
1451
  if (isContext) {
1195
- out.push({ kind: "contextValue", owner: providerOwner || `${ctxName}.Provider`, target: r.component, prop: ctxName, label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`, detail: `${ctxName} produced a new value that is deep-equal to the previous one.` });
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
+ });
1196
1463
  } else if (c.hook === "useSyncExternalStore") {
1197
1464
  const chain = c.custom || [];
1198
1465
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
1199
1466
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
1200
- out.push({ kind: "storeSnapshot", owner: r.component, target: r.component, prop: c.path, label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`, detail: storeAdvice(c.custom) });
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
+ });
1201
1485
  } else {
1202
- out.push({ kind: "bailout", owner: r.component, target: r.component, prop: c.path, label: `bail out before setting ${c.path} in <${r.component}>`, detail: `${c.path} was set to a value deep-equal to the current one (new reference).` });
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
+ });
1203
1496
  }
1204
1497
  }
1205
1498
  return out;
@@ -1213,11 +1506,12 @@ function rankFixes(reports) {
1213
1506
  const key = fixKey(f);
1214
1507
  let agg = byKey.get(key);
1215
1508
  if (!agg) {
1216
- agg = { ...f, key, count: 0, components: {} };
1509
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
1217
1510
  byKey.set(key, agg);
1218
1511
  }
1219
1512
  agg.count++;
1220
1513
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
1514
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
1221
1515
  }
1222
1516
  }
1223
1517
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -1227,6 +1521,8 @@ function formatFixes(reports, limit = 10) {
1227
1521
  if (!ranked.length) return "No avoidable re-renders.";
1228
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(", ")})`);
1229
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);
1230
1526
  return lines.join("\n");
1231
1527
  }
1232
1528
 
@@ -1325,7 +1621,7 @@ ${formatFixes(reports)}`);
1325
1621
  };
1326
1622
  }
1327
1623
 
1328
- // src/vitest.ts
1624
+ // src/runner-report.ts
1329
1625
  var OUT_ENV = "RERENDER_LENS_OUT";
1330
1626
  function setupRerenderLens(options = {}, hooks = {}) {
1331
1627
  ensureDevtoolsHook();
@@ -1360,49 +1656,55 @@ function slim(r) {
1360
1656
  state: void 0
1361
1657
  };
1362
1658
  }
1363
- var RerenderLensReporter = class {
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 {
1364
1694
  constructor(options = {}) {
1365
1695
  this.done = false;
1366
1696
  this.options = options;
1367
- const dir = path.join(os.tmpdir(), "rerender-lens");
1368
- fs.mkdirSync(dir, { recursive: true });
1369
- this.file = path.join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
1370
- fs.writeFileSync(this.file, "");
1371
- process.env[OUT_ENV] = this.file;
1697
+ this.file = createRunFile();
1372
1698
  }
1373
- onInit() {
1699
+ /** Publish the path again (the runner may have replaced `process.env` since the constructor ran). */
1700
+ arm() {
1374
1701
  process.env[OUT_ENV] = this.file;
1375
1702
  }
1376
- onTestRunEnd() {
1377
- this.finish();
1378
- }
1379
- /** Vitest < 3 name for the same event. */
1380
- onFinished() {
1381
- this.finish();
1382
- }
1703
+ /** The run's result, or null when the run was already finished (runners fire two "done" events). */
1383
1704
  finish() {
1384
- if (this.done) return;
1705
+ if (this.done) return null;
1385
1706
  this.done = true;
1386
- const reports = readRun(this.file);
1387
- const log = this.options.write ?? ((line) => void process.stdout.write(line + "\n"));
1388
- const avoidable = reports.filter((r) => r.avoidable).length;
1389
- log("");
1390
- log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
1391
- if (avoidable) log(formatFixes(reports, this.options.limit ?? 10));
1392
- if (this.options.exportTo) fs.writeFileSync(this.options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
1393
- if (this.options.budget) {
1394
- const budget = JSON.parse(fs.readFileSync(this.options.budget, "utf8"));
1395
- const result = checkBudget(reports, budget);
1396
- if (!result.ok) {
1397
- for (const v of result.violations) log(` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
1398
- process.exitCode = 1;
1399
- } else log(` within budget (${this.options.budget})`);
1400
- }
1401
- try {
1402
- fs.rmSync(this.file, { force: true });
1403
- } catch {
1404
- }
1405
- if (process.env[OUT_ENV] === this.file) delete process.env[OUT_ENV];
1707
+ return finishRun(this.file, this.options);
1406
1708
  }
1407
1709
  };
1408
1710
  function readRun(file) {
@@ -1419,6 +1721,27 @@ function readRun(file) {
1419
1721
  return out;
1420
1722
  }
1421
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
+
1422
1745
  exports.OUT_ENV = OUT_ENV;
1423
1746
  exports.checkBudget = checkBudget;
1424
1747
  exports.default = RerenderLensReporter;