rerender-lens 0.4.0 → 0.7.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 (68) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +208 -359
  3. package/dist/{budget-LkNjGtRc.d.ts → budget-CA1MS7lB.d.cts} +20 -10
  4. package/dist/{budget-COBu7jBU.d.cts → budget-ok1VDJ4t.d.ts} +20 -10
  5. package/dist/cli.cjs +322 -53
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +322 -53
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-BkCct3cJ.d.cts → devtools-Cl5n_52v.d.ts} +17 -2
  10. package/dist/{devtools-CZebzpF6.d.ts → devtools-UwQx2VYS.d.cts} +17 -2
  11. package/dist/index.cjs +537 -49
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +137 -8
  14. package/dist/index.d.ts +137 -8
  15. package/dist/index.js +521 -50
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1690 -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 +1688 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1777 -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 +1768 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-B5poAEvE.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-CqQW0Eum.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 +71 -19
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.d.cts +25 -3
  40. package/dist/relay.d.ts +25 -3
  41. package/dist/relay.js +71 -19
  42. package/dist/relay.js.map +1 -1
  43. package/dist/rerender-lens.iife.js +457 -62
  44. package/dist/runner-report-Bvqoe89M.d.ts +46 -0
  45. package/dist/runner-report-CvpDg2zt.d.cts +46 -0
  46. package/dist/setup.cjs +209 -35
  47. package/dist/setup.cjs.map +1 -1
  48. package/dist/setup.js +209 -35
  49. package/dist/setup.js.map +1 -1
  50. package/dist/{types-BzUEVkxJ.d.cts → types-DwNN5cWe.d.cts} +82 -2
  51. package/dist/{types-BzUEVkxJ.d.ts → types-DwNN5cWe.d.ts} +82 -2
  52. package/dist/vite.d.cts +1 -1
  53. package/dist/vite.d.ts +1 -1
  54. package/dist/vitest-setup.cjs +357 -44
  55. package/dist/vitest-setup.cjs.map +1 -1
  56. package/dist/vitest-setup.d.cts +4 -4
  57. package/dist/vitest-setup.d.ts +4 -4
  58. package/dist/vitest-setup.js +357 -44
  59. package/dist/vitest-setup.js.map +1 -1
  60. package/dist/vitest.cjs +420 -80
  61. package/dist/vitest.cjs.map +1 -1
  62. package/dist/vitest.d.cts +21 -39
  63. package/dist/vitest.d.ts +21 -39
  64. package/dist/vitest.js +421 -81
  65. package/dist/vitest.js.map +1 -1
  66. package/package.json +41 -4
  67. package/panel/panel.css +49 -0
  68. package/panel/panel.js +871 -353
@@ -129,6 +129,7 @@ function deepEqualObjects(a, b, seen) {
129
129
  }
130
130
  return false;
131
131
  }
132
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
132
133
  function classify(prev, next) {
133
134
  if (typeof prev === "function" && typeof next === "function") {
134
135
  return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
@@ -165,30 +166,30 @@ function diffRecords(prev, next, basePath = "") {
165
166
  if (Object.is(a, b)) continue;
166
167
  const kind = classify(a, b);
167
168
  if (kind === "different") {
168
- changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path), kind, prev: a, next: b });
169
+ changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
169
170
  } else {
170
171
  changes.push({ path, kind, prev: a, next: b });
171
172
  }
172
173
  }
173
174
  return changes;
174
175
  }
175
- function firstDifferentPath(a, b, path, depth = 0) {
176
- if (depth > 8) return path;
176
+ function firstDifferentPath(a, b, path = "", depth = 0) {
177
+ if (depth > 8) return path || "(value)";
177
178
  if (Array.isArray(a) && Array.isArray(b)) {
178
- if (a.length !== b.length) return `${path}.length`;
179
+ if (a.length !== b.length) return path ? `${path}.length` : "length";
179
180
  for (let i = 0; i < a.length; i++) {
180
181
  if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
181
182
  }
182
- return path;
183
+ return null;
183
184
  }
184
185
  if (isPlainObject(a) && isPlainObject(b)) {
185
186
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
186
187
  for (const k of keys) {
187
188
  if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
188
189
  }
189
- return path;
190
+ return null;
190
191
  }
191
- return path;
192
+ return deepEqual(a, b) ? null : path || "(value)";
192
193
  }
193
194
 
194
195
  // src/report.ts
@@ -206,10 +207,12 @@ function storeAdvice(custom) {
206
207
  }
207
208
  var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
208
209
  var isChildren = (change) => change.path === "children";
210
+ var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
209
211
  function fixFor(change) {
210
212
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
211
213
  return `lift the children out of the parent's render: memoize them with useMemo, hoist static elements to module scope, or render them from a component that does not re-render`;
212
214
  }
215
+ if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
213
216
  switch (change.kind) {
214
217
  case "function":
215
218
  return `wrap it in useCallback (or hoist it out of the parent's render)`;
@@ -225,6 +228,7 @@ function describe(change) {
225
228
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
226
229
  return `children are new React elements with the same types and props on every render of the parent`;
227
230
  }
231
+ if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
228
232
  switch (change.kind) {
229
233
  case "deep-equal":
230
234
  return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
@@ -252,11 +256,21 @@ function buildReport(input) {
252
256
  if (causes.length === 0) trigger = "parent";
253
257
  else if (causes.length === 1) trigger = causes[0];
254
258
  else trigger = "mixed";
255
- const avoidable = trigger === "parent";
259
+ const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
260
+ const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
261
+ const revealed = input.revealed === true && trigger === "parent";
262
+ const avoidable = trigger === "parent" && !cacheHit && !revealed;
256
263
  const reasons = [];
257
- if (avoidable && input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0) {
264
+ const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
265
+ if (cacheHit) {
266
+ reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
267
+ } else if (revealed) {
268
+ reasons.push(
269
+ `re-rendered because the Suspense boundary above it switched from its fallback to content: React re-renders the content it kept hidden while the fallback was shown. A one-off; nothing to fix.`
270
+ );
271
+ } else if (avoidable && nothingChanged) {
258
272
  const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
259
- reasons.push(`re-rendered with identical props because ${who}. Wrap "${input.component}" in React.memo (or extend PureComponent).`);
273
+ reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
260
274
  } else if (avoidable && input.parent) {
261
275
  reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
262
276
  }
@@ -265,9 +279,9 @@ function buildReport(input) {
265
279
  reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
266
280
  }
267
281
  const memoized = input.memoized !== false;
268
- if (avoidable && !memoized && input.propChanges.length > 0) {
282
+ if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
269
283
  reasons.push(
270
- `"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also wrap it in React.memo (or extend PureComponent).`
284
+ `"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also ${input.classComponent ? "extend PureComponent (or implement shouldComponentUpdate)" : "wrap it in React.memo (or extend PureComponent)"}.`
271
285
  );
272
286
  }
273
287
  for (const c of stateChanges) {
@@ -279,6 +293,8 @@ function buildReport(input) {
279
293
  const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
280
294
  const keys = c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys ? `: only ${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed, yet every consumer re-renders. Split the context or memoize the slices consumers read` : "";
281
295
  reasons.push(`${c.path} changed${where}${keys}.`);
296
+ } else if (c.hook === "useDeferredValue" && isGenuine(c)) {
297
+ reasons.push(`${hookLabel(c)} caught up with its latest value: this is the deferred second render React schedules after the urgent one; nothing to fix.`);
282
298
  } else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
283
299
  else if (isStateHook(c))
284
300
  reasons.push(`${hookLabel(c)} was set to a value deep-equal to the current one (new reference, same contents): reuse the existing object or bail out before calling the setter.`);
@@ -309,6 +325,7 @@ function buildReport(input) {
309
325
  reasons,
310
326
  time: now()
311
327
  };
328
+ if (input.compiled) report.compiled = true;
312
329
  if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
313
330
  if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
314
331
  if (input.commitPriority) report.commitPriority = input.commitPriority;
@@ -346,10 +363,10 @@ var KIND_LABEL = {
346
363
  };
347
364
  function summarize(report) {
348
365
  const counts2 = /* @__PURE__ */ new Map();
349
- for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
366
+ for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
350
367
  counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
351
368
  }
352
- const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
369
+ const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
353
370
  if (parts.length === 0) parts.push("no changes");
354
371
  return parts.join(", ");
355
372
  }
@@ -371,16 +388,27 @@ function printReport(report, options) {
371
388
  }
372
389
 
373
390
  // src/state.ts
391
+ var MAX_SEEN_COMPONENTS = 500;
374
392
  var KEY = /* @__PURE__ */ Symbol.for("rerender-lens.state");
375
393
  function getState() {
376
394
  const g = globalThis;
377
395
  let s = g[KEY];
378
396
  if (!s) {
379
- s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0, truncated: 0 };
397
+ s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0, truncated: 0, seen: /* @__PURE__ */ new Set(), seenTracked: /* @__PURE__ */ new Set(), seenOverflow: false };
380
398
  g[KEY] = s;
381
399
  }
382
400
  return s;
383
401
  }
402
+ function noteRendered(s, name, tracked) {
403
+ if (!s.seen.has(name)) {
404
+ if (s.seen.size >= MAX_SEEN_COMPONENTS) {
405
+ s.seenOverflow = true;
406
+ return;
407
+ }
408
+ s.seen.add(name);
409
+ }
410
+ if (tracked) s.seenTracked.add(name);
411
+ }
384
412
  function warnOnce(key, message) {
385
413
  const s = getState();
386
414
  if (s.warnedOnce.has(key)) return;
@@ -458,6 +486,9 @@ function init(options = {}) {
458
486
  const s = getState();
459
487
  s.options = { ...options };
460
488
  s.printed.clear();
489
+ s.seen.clear();
490
+ s.seenTracked.clear();
491
+ s.seenOverflow = false;
461
492
  if (!s.enabled) {
462
493
  s.detach = attach();
463
494
  s.enabled = true;
@@ -473,6 +504,27 @@ function disable() {
473
504
  }
474
505
 
475
506
  // src/hookNames.ts
507
+ var NODE_COUNT = {
508
+ useState: 1,
509
+ useReducer: 1,
510
+ useRef: 1,
511
+ useMemo: 1,
512
+ useCallback: 1,
513
+ useEffect: 1,
514
+ useLayoutEffect: 1,
515
+ useInsertionEffect: 1,
516
+ useImperativeHandle: 1,
517
+ useSyncExternalStore: 2,
518
+ useTransition: 2,
519
+ useDeferredValue: 1,
520
+ useId: 1,
521
+ useOptimistic: 1,
522
+ useActionState: 3,
523
+ useFormState: 3,
524
+ useEffectEvent: 1,
525
+ useCacheRefresh: 1
526
+ };
527
+ var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
476
528
  var noop = () => {
477
529
  };
478
530
  function customHooksFromStack(stack, componentName, marker) {
@@ -487,7 +539,7 @@ function customHooksFromStack(stack, componentName, marker) {
487
539
  continue;
488
540
  }
489
541
  if (name === componentName || name.endsWith("." + componentName)) break;
490
- if (/^use[A-Z0-9_]/.test(name)) names.push(name);
542
+ if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
491
543
  if (names.length > 8) break;
492
544
  }
493
545
  return names;
@@ -771,6 +823,11 @@ function isMemoizedFiber(fiber) {
771
823
  }
772
824
  return false;
773
825
  }
826
+ function isCompiledFiber(fiber) {
827
+ if (fiber.tag === ClassComponent) return false;
828
+ const cache2 = fiber.updateQueue?.memoCache;
829
+ return !!cache2 && Array.isArray(cache2.data);
830
+ }
774
831
  function durationsOf(fiber) {
775
832
  if (typeof fiber.actualDuration !== "number") return null;
776
833
  const tree = fiber.actualDuration;
@@ -923,7 +980,8 @@ function diffHooks(fiber, alt) {
923
980
  const labels = hookLabelsFor(fiber, b);
924
981
  let i = 0;
925
982
  while (a && b) {
926
- if ((isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
983
+ const deferred = labels?.[i] === "useDeferredValue";
984
+ if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
927
985
  const hook = labels?.[i] ?? hookLabel2(b);
928
986
  out.push({
929
987
  path: `${hook}#${i}`,
@@ -957,6 +1015,10 @@ function diffContexts(fiber, alt) {
957
1015
  let a = alt.dependencies?.firstContext ?? null;
958
1016
  let b = fiber.dependencies?.firstContext ?? null;
959
1017
  let i = 0;
1018
+ if (b && !("memoizedValue" in b)) {
1019
+ warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
1020
+ return out;
1021
+ }
960
1022
  while (a && b) {
961
1023
  if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
962
1024
  const name = b.context.displayName ?? "Context";
@@ -989,10 +1051,26 @@ function diffContexts(fiber, alt) {
989
1051
  return out;
990
1052
  }
991
1053
  var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
1054
+ var isRefObject2 = (v) => {
1055
+ if (typeof v !== "object" || v === null) return false;
1056
+ const keys = Object.keys(v);
1057
+ return keys.length === 1 && keys[0] === "current";
1058
+ };
1059
+ function diffProps(prev, next) {
1060
+ if (prev === next) return [];
1061
+ const pr = prev.ref;
1062
+ const nr = next.ref;
1063
+ if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
1064
+ const { ref: _p, ...restPrev } = prev;
1065
+ const { ref: _n, ...restNext } = next;
1066
+ return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
1067
+ }
1068
+ return diffRecords(prev, next);
1069
+ }
992
1070
  function analyze(fiber, alt, trackHooks) {
993
1071
  const prevProps = alt.memoizedProps ?? {};
994
1072
  const nextProps = fiber.memoizedProps ?? {};
995
- const propChanges = prevProps === nextProps ? [] : diffRecords(prevProps, nextProps);
1073
+ const propChanges = diffProps(prevProps, nextProps);
996
1074
  let stateChanges = [];
997
1075
  let hookChanges = [];
998
1076
  if (fiber.tag === ClassComponent) {
@@ -1005,7 +1083,7 @@ function analyze(fiber, alt, trackHooks) {
1005
1083
  } else if (trackHooks) {
1006
1084
  hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
1007
1085
  }
1008
- const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
1086
+ const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
1009
1087
  const causes = [];
1010
1088
  if (propChanges.some(isGenuine2)) causes.push("props");
1011
1089
  if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
@@ -1045,6 +1123,24 @@ var lastCommit = null;
1045
1123
  var EFFECT_LOOP_WINDOW_MS = 50;
1046
1124
  var MAX_REPORTS_PER_COMMIT = 200;
1047
1125
  var COMMIT_TIME_BUDGET_MS = 25;
1126
+ function isDeferredCatchUp(fiber) {
1127
+ const types = fiber._debugHookTypes;
1128
+ if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
1129
+ const first = fiber.memoizedState;
1130
+ if (!isHookList(first)) return false;
1131
+ const labels = hookLabelsFor(fiber, first);
1132
+ if (!labels) return false;
1133
+ let a = fiber.alternate.memoizedState;
1134
+ let b = first;
1135
+ for (let i = 0; a && b; a = a.next, b = b.next, i++) {
1136
+ if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
1137
+ }
1138
+ return false;
1139
+ }
1140
+ function isUnder(fiber, boundaries) {
1141
+ for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
1142
+ return false;
1143
+ }
1048
1144
  function onCommit(root, commitPriority) {
1049
1145
  const s = getState();
1050
1146
  if (!s.enabled) return;
@@ -1054,17 +1150,20 @@ function onCommit(root, commitPriority) {
1054
1150
  const rendered = [];
1055
1151
  const renderedNames = /* @__PURE__ */ new Set();
1056
1152
  let hot = false;
1057
- let suspenseResolved = false;
1153
+ let resolvedBoundaries = null;
1058
1154
  const stack = [root.current];
1059
1155
  while (stack.length) {
1060
1156
  const fiber = stack.pop();
1061
1157
  const alt = fiber.alternate;
1062
1158
  if (alt && isComponentTag(fiber.tag) && didRender(fiber)) {
1063
1159
  if (isHotSwapped(fiber, alt)) hot = true;
1064
- renderedNames.add(fiberName(fiber));
1065
- if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
1160
+ const name = fiberName(fiber);
1161
+ renderedNames.add(name);
1162
+ const tracked = shouldTrack(fiberType(fiber), o);
1163
+ noteRendered(s, name, tracked);
1164
+ if (tracked) rendered.push(fiber);
1066
1165
  }
1067
- if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) suspenseResolved = true;
1166
+ if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
1068
1167
  if (!alt || fiber.child !== alt.child) {
1069
1168
  for (let c = fiber.child; c; c = c.sibling) stack.push(c);
1070
1169
  }
@@ -1076,10 +1175,10 @@ function onCommit(root, commitPriority) {
1076
1175
  let commitCause;
1077
1176
  let afterCommit;
1078
1177
  const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
1079
- if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
1178
+ if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
1080
1179
  commitCause = "effect-after-commit";
1081
1180
  afterCommit = lastCommit.id;
1082
- } else if (suspenseResolved) commitCause = "suspense-resolved";
1181
+ } else if (resolvedBoundaries) commitCause = "suspense-resolved";
1083
1182
  if (rendered.length === 0) {
1084
1183
  lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
1085
1184
  return;
@@ -1131,6 +1230,9 @@ function onCommit(root, commitPriority) {
1131
1230
  owner: ownerName(fiber),
1132
1231
  path: componentPath(fiber),
1133
1232
  memoized: isMemoizedFiber(fiber),
1233
+ compiled: isCompiledFiber(fiber),
1234
+ classComponent: fiber.tag === ClassComponent,
1235
+ revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
1134
1236
  selfDuration: durations ? durations.self : void 0,
1135
1237
  treeDuration: durations ? durations.tree : void 0,
1136
1238
  commitId,
@@ -1144,38 +1246,203 @@ function onCommit(root, commitPriority) {
1144
1246
  }
1145
1247
  }
1146
1248
 
1249
+ // src/causes.ts
1250
+ 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);
1251
+ var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
1252
+ function indexByComponent(reports) {
1253
+ const index = /* @__PURE__ */ new Map();
1254
+ for (const r of reports) {
1255
+ const list = index.get(r.component);
1256
+ if (list) list.push(r);
1257
+ else index.set(r.component, [r]);
1258
+ }
1259
+ return index;
1260
+ }
1261
+ function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
1262
+ let cur = r;
1263
+ const seen = /* @__PURE__ */ new Set([r]);
1264
+ while (cur.trigger === "parent" && cur.parent) {
1265
+ const parent = cur.parent;
1266
+ const candidates = index.get(parent.name);
1267
+ const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
1268
+ if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
1269
+ seen.add(p);
1270
+ cur = p;
1271
+ }
1272
+ return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
1273
+ }
1274
+ function rootCausesOf(reports) {
1275
+ const roots = /* @__PURE__ */ new Map();
1276
+ const rootByReport = /* @__PURE__ */ new Map();
1277
+ const index = indexByComponent(reports);
1278
+ let avoidable = 0;
1279
+ let wasted = 0;
1280
+ for (const r of reports) {
1281
+ if (!r.avoidable) continue;
1282
+ avoidable++;
1283
+ if (typeof r.selfDuration === "number") wasted += r.selfDuration;
1284
+ const root = rootCauseOf(r, reports, index);
1285
+ const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
1286
+ const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
1287
+ rootByReport.set(r, name);
1288
+ let agg = roots.get(name);
1289
+ if (!agg) {
1290
+ agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
1291
+ roots.set(name, agg);
1292
+ }
1293
+ agg.count++;
1294
+ bump(agg.components, r.component);
1295
+ }
1296
+ return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
1297
+ }
1298
+ function groupByCommit(reports) {
1299
+ const out = /* @__PURE__ */ new Map();
1300
+ for (const r of reports) {
1301
+ if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
1302
+ const list = out.get(r.commitId);
1303
+ if (list) list.push(r);
1304
+ else out.set(r.commitId, [r]);
1305
+ }
1306
+ return out;
1307
+ }
1308
+ function rankRootCauses(reports) {
1309
+ const byName = /* @__PURE__ */ new Map();
1310
+ for (const commit of groupByCommit(reports).values()) {
1311
+ for (const root of rootCausesOf(commit).roots) {
1312
+ let agg = byName.get(root.name);
1313
+ if (!agg) {
1314
+ agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
1315
+ byName.set(root.name, agg);
1316
+ }
1317
+ agg.commits++;
1318
+ agg.count += root.count;
1319
+ for (const [c, n] of root.components) bump(agg.components, c, n);
1320
+ }
1321
+ }
1322
+ return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
1323
+ }
1324
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1325
+ function formatRootCauses(reports, limit = 5) {
1326
+ const ranked = rankRootCauses(reports);
1327
+ 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(", ")})`);
1328
+ if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
1329
+ return lines.join("\n");
1330
+ }
1331
+
1147
1332
  // src/fixes.ts
1148
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
1333
+ var MAX_FIX_REPORTS = 50;
1149
1334
  var rootOf = (path) => path.split(/[.[]/)[0] || path;
1335
+ var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
1336
+ function shortValue(v, max = 60) {
1337
+ let s;
1338
+ try {
1339
+ s = JSON.stringify(v);
1340
+ } catch {
1341
+ s = String(v);
1342
+ }
1343
+ if (s === void 0) s = String(v);
1344
+ return s.length > max ? s.slice(0, max - 3) + "..." : s;
1345
+ }
1150
1346
  function fixesFor(r) {
1151
1347
  const out = [];
1152
- const owner = r.owner || r.parent && r.parent.name || "?";
1153
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
1348
+ const ownerName2 = r.owner || r.parent && r.parent.name || null;
1349
+ const propChanges = r.propChanges || [];
1350
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
1154
1351
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
1352
+ const identical = changes.length === 0;
1155
1353
  out.push({
1156
1354
  kind: "memo",
1157
1355
  owner: r.component,
1158
1356
  target: r.component,
1159
1357
  prop: null,
1160
1358
  label: `Wrap <${r.component}> in React.memo`,
1161
- 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.`
1359
+ 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.`,
1360
+ snippet: `// ${r.component}
1361
+ import { memo } from 'react';
1362
+
1363
+ export const ${r.component} = memo(function ${r.component}(props) {
1364
+ // ...
1365
+ });
1366
+ // class components: extend PureComponent instead`
1162
1367
  });
1163
1368
  }
1164
- for (const c of r.propChanges) {
1165
- if (!AVOIDABLE.has(c.kind)) continue;
1369
+ for (const c of propChanges) {
1370
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1371
+ const owner = ownerName2 || "?";
1166
1372
  const root = rootOf(c.path);
1373
+ const id = identifier(root);
1167
1374
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
1168
- 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.` });
1375
+ out.push({
1376
+ kind: "children",
1377
+ owner,
1378
+ target: r.component,
1379
+ prop: "children",
1380
+ label: `memoize children of <${r.component}> in <${owner}>`,
1381
+ detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
1382
+ snippet: `// ${owner}
1383
+ import { useMemo } from 'react';
1384
+
1385
+ const children = useMemo(() => (
1386
+ <>{/* the same elements */}</>
1387
+ ), [/* deps */]);
1388
+
1389
+ <${r.component}>{children}</${r.component}>
1390
+
1391
+ // static children: hoist them to module scope
1392
+ const STATIC = <em>hi</em>;`
1393
+ });
1169
1394
  } else if (c.kind === "function") {
1170
- 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}>.` });
1395
+ out.push({
1396
+ kind: "useCallback",
1397
+ owner,
1398
+ target: r.component,
1399
+ prop: root,
1400
+ label: `useCallback(${root}) in <${owner}>`,
1401
+ detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
1402
+ snippet: `// ${owner}
1403
+ import { useCallback } from 'react';
1404
+
1405
+ const ${id} = useCallback((/* args */) => {
1406
+ // ...
1407
+ }, [/* deps */]);
1408
+
1409
+ <${r.component} ${root}={${id}} />`
1410
+ });
1171
1411
  } else if (c.kind === "element") {
1172
- 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}>.` });
1412
+ out.push({
1413
+ kind: "useMemoElement",
1414
+ owner,
1415
+ target: r.component,
1416
+ prop: root,
1417
+ label: `memoize element prop ${root} in <${owner}>`,
1418
+ detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
1419
+ snippet: `// ${owner}
1420
+ import { useMemo } from 'react';
1421
+
1422
+ const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
1423
+ // or pass it as children from a component that does not re-render`
1424
+ });
1173
1425
  } else {
1174
- 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}>.` });
1426
+ const isArray = Array.isArray(c.next);
1427
+ out.push({
1428
+ kind: "useMemo",
1429
+ owner,
1430
+ target: r.component,
1431
+ prop: root,
1432
+ label: `useMemo(${root}) in <${owner}>`,
1433
+ detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
1434
+ snippet: `// ${owner}
1435
+ import { useMemo } from 'react';
1436
+
1437
+ const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
1438
+
1439
+ // or, when it never changes, hoist it to module scope:
1440
+ const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
1441
+ });
1175
1442
  }
1176
1443
  }
1177
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
1178
- const isContext = c.hook === "useContext";
1444
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
1445
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
1179
1446
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
1180
1447
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
1181
1448
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -1185,20 +1452,63 @@ function fixesFor(r) {
1185
1452
  target: r.component,
1186
1453
  prop: ctxName,
1187
1454
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
1188
- detail: `${c.changedKeys.length} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer re-rendered.`
1455
+ 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.`,
1456
+ snippet: `// ${providerOwner || "Provider"}
1457
+ // one context per independently-changing slice
1458
+ const ${identifier(ctxName)}Static = createContext(...);
1459
+ const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
1460
+
1461
+ // or keep one context and let consumers select a slice:
1462
+ const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
1189
1463
  });
1190
1464
  continue;
1191
1465
  }
1192
- if (!AVOIDABLE.has(c.kind)) continue;
1466
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1193
1467
  if (isContext) {
1194
- 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.` });
1468
+ out.push({
1469
+ kind: "contextValue",
1470
+ owner: providerOwner || `${ctxName}.Provider`,
1471
+ target: r.component,
1472
+ prop: ctxName,
1473
+ label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
1474
+ detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
1475
+ snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
1476
+ const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
1477
+ <${ctxName}.Provider value={value}>`
1478
+ });
1195
1479
  } else if (c.hook === "useSyncExternalStore") {
1196
1480
  const chain = c.custom || [];
1197
1481
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
1198
1482
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
1199
- 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) });
1483
+ const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
1484
+ out.push({
1485
+ kind: "storeSnapshot",
1486
+ owner: r.component,
1487
+ target: r.component,
1488
+ prop: c.path,
1489
+ label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
1490
+ 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.`,
1491
+ snippet: redux ? `// ${r.component}
1492
+ import { shallowEqual } from 'react-redux';
1493
+ const slice = useSelector(selectSlice, shallowEqual);
1494
+ // or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
1495
+ import { useShallow } from 'zustand/react/shallow';
1496
+ const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
1497
+ // or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
1498
+ // getSnapshot must return the same reference while the data is unchanged
1499
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
1500
+ });
1200
1501
  } else {
1201
- 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).` });
1502
+ out.push({
1503
+ kind: "bailout",
1504
+ owner: r.component,
1505
+ target: r.component,
1506
+ prop: c.path,
1507
+ label: `bail out before setting ${c.path} in <${r.component}>`,
1508
+ detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
1509
+ snippet: `// ${r.component}
1510
+ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1511
+ });
1202
1512
  }
1203
1513
  }
1204
1514
  return out;
@@ -1212,11 +1522,12 @@ function rankFixes(reports) {
1212
1522
  const key = fixKey(f);
1213
1523
  let agg = byKey.get(key);
1214
1524
  if (!agg) {
1215
- agg = { ...f, key, count: 0, components: {} };
1525
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
1216
1526
  byKey.set(key, agg);
1217
1527
  }
1218
1528
  agg.count++;
1219
1529
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
1530
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
1220
1531
  }
1221
1532
  }
1222
1533
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -1226,6 +1537,8 @@ function formatFixes(reports, limit = 10) {
1226
1537
  if (!ranked.length) return "No avoidable re-renders.";
1227
1538
  const lines = ranked.slice(0, limit).map((f, i) => `${String(i + 1).padStart(2)}. ${f.label} (removes ${f.count}: ${Object.entries(f.components).map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
1228
1539
  if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
1540
+ const roots = formatRootCauses(reports, 5);
1541
+ if (roots) lines.push("", "Root causes:", roots);
1229
1542
  return lines.join("\n");
1230
1543
  }
1231
1544
 
@@ -1324,7 +1637,7 @@ ${formatFixes(reports)}`);
1324
1637
  };
1325
1638
  }
1326
1639
 
1327
- // src/vitest.ts
1640
+ // src/runner-report.ts
1328
1641
  var OUT_ENV = "RERENDER_LENS_OUT";
1329
1642
  function setupRerenderLens(options = {}, hooks = {}) {
1330
1643
  ensureDevtoolsHook();