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