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