rerender-lens 0.3.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 +118 -0
  2. package/README.md +192 -353
  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-BHGUUo-p.d.ts → devtools-BYWat7nQ.d.ts} +17 -3
  10. package/dist/{devtools-DmhWiWcN.d.cts → devtools-DJi_6AfJ.d.cts} +17 -3
  11. package/dist/index.cjs +635 -109
  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 +620 -110
  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 +235 -30
  32. package/dist/playwright.cjs.map +1 -1
  33. package/dist/playwright.d.cts +8 -6
  34. package/dist/playwright.d.ts +8 -6
  35. package/dist/playwright.js +235 -31
  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 +1100 -337
  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 +308 -95
  45. package/dist/setup.cjs.map +1 -1
  46. package/dist/setup.js +308 -95
  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 +452 -87
  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 +452 -87
  57. package/dist/vitest-setup.js.map +1 -1
  58. package/dist/vitest.cjs +515 -123
  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 +516 -124
  63. package/dist/vitest.js.map +1 -1
  64. package/package.json +41 -5
  65. package/panel/panel.css +1 -34
  66. package/panel/panel.js +865 -713
@@ -28,10 +28,49 @@ function remember(seen, a, b) {
28
28
  set.add(b);
29
29
  return false;
30
30
  }
31
+ var scope = null;
32
+ var DEFAULT_DIFF_BUDGET = 5e4;
33
+ function beginDiffScope(budget = 2e5) {
34
+ const previous = scope;
35
+ scope = { cache: /* @__PURE__ */ new WeakMap(), remaining: budget, exhausted: false };
36
+ return () => {
37
+ scope = previous;
38
+ };
39
+ }
40
+ function diffBudgetExhausted() {
41
+ return scope?.exhausted === true;
42
+ }
31
43
  function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
32
44
  if (Object.is(a, b)) return true;
33
45
  if (typeof a !== typeof b) return false;
34
46
  if (typeof a !== "object" || a === null || b === null) return false;
47
+ if (!scope) {
48
+ const end = beginDiffScope(DEFAULT_DIFF_BUDGET);
49
+ try {
50
+ return deepEqual(a, b, seen);
51
+ } finally {
52
+ end();
53
+ }
54
+ }
55
+ const sc = scope;
56
+ const objA = a;
57
+ const objB = b;
58
+ const hit = sc.cache.get(objA)?.get(objB);
59
+ if (hit !== void 0) return hit;
60
+ if (--sc.remaining < 0) {
61
+ sc.exhausted = true;
62
+ return false;
63
+ }
64
+ const result = deepEqualObjects(objA, objB, seen);
65
+ let m = sc.cache.get(objA);
66
+ if (!m) {
67
+ m = /* @__PURE__ */ new WeakMap();
68
+ sc.cache.set(objA, m);
69
+ }
70
+ m.set(objB, result);
71
+ return result;
72
+ }
73
+ function deepEqualObjects(a, b, seen) {
35
74
  const objA = a;
36
75
  const objB = b;
37
76
  if (remember(seen, objA, objB)) return true;
@@ -60,6 +99,10 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
60
99
  }
61
100
  if (a instanceof Set) {
62
101
  if (!(b instanceof Set) || a.size !== b.size) return false;
102
+ if (a.size > 50) {
103
+ for (const v of a) if (!b.has(v)) return false;
104
+ return true;
105
+ }
63
106
  outer: for (const v of a) {
64
107
  if (b.has(v)) continue;
65
108
  for (const w of b) if (deepEqual(v, w, seen)) continue outer;
@@ -75,14 +118,16 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
75
118
  const ka = Object.keys(a);
76
119
  const kb = Object.keys(b);
77
120
  if (ka.length !== kb.length) return false;
121
+ const rb = b;
78
122
  for (const k of ka) {
79
123
  if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
80
- if (!deepEqual(a[k], b[k], seen)) return false;
124
+ if (!deepEqual(a[k], rb[k], seen)) return false;
81
125
  }
82
126
  return true;
83
127
  }
84
128
  return false;
85
129
  }
130
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
86
131
  function classify(prev, next) {
87
132
  if (typeof prev === "function" && typeof next === "function") {
88
133
  return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
@@ -119,30 +164,30 @@ function diffRecords(prev, next, basePath = "") {
119
164
  if (Object.is(a, b)) continue;
120
165
  const kind = classify(a, b);
121
166
  if (kind === "different") {
122
- changes.push({ 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 });
123
168
  } else {
124
169
  changes.push({ path, kind, prev: a, next: b });
125
170
  }
126
171
  }
127
172
  return changes;
128
173
  }
129
- function firstDifferentPath(a, b, path, depth = 0) {
130
- if (depth > 8) return path;
174
+ function firstDifferentPath(a, b, path = "", depth = 0) {
175
+ if (depth > 8) return path || "(value)";
131
176
  if (Array.isArray(a) && Array.isArray(b)) {
132
- if (a.length !== b.length) return `${path}.length`;
177
+ if (a.length !== b.length) return path ? `${path}.length` : "length";
133
178
  for (let i = 0; i < a.length; i++) {
134
179
  if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
135
180
  }
136
- return path;
181
+ return null;
137
182
  }
138
183
  if (isPlainObject(a) && isPlainObject(b)) {
139
184
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
140
185
  for (const k of keys) {
141
186
  if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
142
187
  }
143
- return path;
188
+ return null;
144
189
  }
145
- return path;
190
+ return deepEqual(a, b) ? null : path || "(value)";
146
191
  }
147
192
 
148
193
  // src/report.ts
@@ -160,10 +205,12 @@ function storeAdvice(custom) {
160
205
  }
161
206
  var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
162
207
  var isChildren = (change) => change.path === "children";
208
+ var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
163
209
  function fixFor(change) {
164
210
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
165
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`;
166
212
  }
213
+ if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
167
214
  switch (change.kind) {
168
215
  case "function":
169
216
  return `wrap it in useCallback (or hoist it out of the parent's render)`;
@@ -179,6 +226,7 @@ function describe(change) {
179
226
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
180
227
  return `children are new React elements with the same types and props on every render of the parent`;
181
228
  }
229
+ if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
182
230
  switch (change.kind) {
183
231
  case "deep-equal":
184
232
  return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
@@ -206,11 +254,21 @@ function buildReport(input) {
206
254
  if (causes.length === 0) trigger = "parent";
207
255
  else if (causes.length === 1) trigger = causes[0];
208
256
  else trigger = "mixed";
209
- 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;
210
261
  const reasons = [];
211
- 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) {
212
270
  const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
213
- 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}.`);
214
272
  } else if (avoidable && input.parent) {
215
273
  reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
216
274
  }
@@ -219,9 +277,9 @@ function buildReport(input) {
219
277
  reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
220
278
  }
221
279
  const memoized = input.memoized !== false;
222
- if (avoidable && !memoized && input.propChanges.length > 0) {
280
+ if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
223
281
  reasons.push(
224
- `"${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)"}.`
225
283
  );
226
284
  }
227
285
  for (const c of stateChanges) {
@@ -233,6 +291,8 @@ function buildReport(input) {
233
291
  const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
234
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` : "";
235
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.`);
236
296
  } else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
237
297
  else if (isStateHook(c))
238
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.`);
@@ -263,6 +323,7 @@ function buildReport(input) {
263
323
  reasons,
264
324
  time: now()
265
325
  };
326
+ if (input.compiled) report.compiled = true;
266
327
  if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
267
328
  if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
268
329
  if (input.commitPriority) report.commitPriority = input.commitPriority;
@@ -300,10 +361,10 @@ var KIND_LABEL = {
300
361
  };
301
362
  function summarize(report) {
302
363
  const counts2 = /* @__PURE__ */ new Map();
303
- for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
364
+ for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
304
365
  counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
305
366
  }
306
- const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
367
+ const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
307
368
  if (parts.length === 0) parts.push("no changes");
308
369
  return parts.join(", ");
309
370
  }
@@ -330,7 +391,7 @@ function getState() {
330
391
  const g = globalThis;
331
392
  let s = g[KEY];
332
393
  if (!s) {
333
- 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 };
394
+ 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 };
334
395
  g[KEY] = s;
335
396
  }
336
397
  return s;
@@ -360,7 +421,7 @@ function dispatch(report, override) {
360
421
  try {
361
422
  options.notifier(report);
362
423
  } catch (err) {
363
- (options.console ?? console).warn("[rerender-lens] notifier threw", err);
424
+ warnOnce("notifier", `notifier threw: ${String(err)}`);
364
425
  }
365
426
  }
366
427
  }
@@ -427,6 +488,27 @@ function disable() {
427
488
  }
428
489
 
429
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"]);
430
512
  var noop = () => {
431
513
  };
432
514
  function customHooksFromStack(stack, componentName, marker) {
@@ -441,7 +523,7 @@ function customHooksFromStack(stack, componentName, marker) {
441
523
  continue;
442
524
  }
443
525
  if (name === componentName || name.endsWith("." + componentName)) break;
444
- 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);
445
527
  if (names.length > 8) break;
446
528
  }
447
529
  return names;
@@ -725,6 +807,11 @@ function isMemoizedFiber(fiber) {
725
807
  }
726
808
  return false;
727
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
+ }
728
815
  function durationsOf(fiber) {
729
816
  if (typeof fiber.actualDuration !== "number") return null;
730
817
  const tree = fiber.actualDuration;
@@ -748,11 +835,14 @@ var counts = /* @__PURE__ */ new WeakMap();
748
835
  var ids = /* @__PURE__ */ new WeakMap();
749
836
  var fibersById = /* @__PURE__ */ new Map();
750
837
  var hasWeakRef = typeof WeakRef === "function";
838
+ var pruneAt = 5e3;
751
839
  function remember2(id, fiber) {
752
840
  if (!hasWeakRef) return;
841
+ if (fibersById.get(id)?.deref() === fiber) return;
753
842
  fibersById.set(id, new WeakRef(fiber));
754
- if (fibersById.size > 5e3) {
843
+ if (fibersById.size > pruneAt) {
755
844
  for (const [k, ref] of fibersById) if (!ref.deref()) fibersById.delete(k);
845
+ pruneAt = Math.max(5e3, fibersById.size * 2);
756
846
  }
757
847
  }
758
848
  function instanceIdOf(fiber) {
@@ -781,7 +871,7 @@ function nearestComponent(fiber) {
781
871
  }
782
872
  var INTERNAL_FRAME = /react-dom|react_jsx|jsx-(dev-)?runtime|\/react\/|node_modules\/react|react-stack-bottom-frame|react_stack_bottom_frame|scheduler/;
783
873
  function parseStackLocation(stack) {
784
- for (const line of stack.split("\n")) {
874
+ for (const line of stack.split("\n", 24)) {
785
875
  const m = /(?:at\s+(?:.*?\s+)?\(?|@)?((?:https?|file|webpack|vite|blob):[^\s()]+?):(\d+):(\d+)\)?\s*$/.exec(line.trim());
786
876
  if (!m || !m[1] || INTERNAL_FRAME.test(m[1])) continue;
787
877
  return { fileName: m[1], lineNumber: Number(m[2]), columnNumber: Number(m[3]) };
@@ -797,9 +887,16 @@ function sourceOf(fiber) {
797
887
  return out;
798
888
  }
799
889
  const st = fiber._debugStack;
800
- const text = typeof st === "string" ? st : st && typeof st.stack === "string" ? st.stack : null;
801
- return text ? parseStackLocation(text) : void 0;
890
+ if (st && typeof st === "object") {
891
+ if (sourceCache.has(st)) return sourceCache.get(st);
892
+ const text = typeof st.stack === "string" ? st.stack : null;
893
+ const out = text ? parseStackLocation(text) : void 0;
894
+ sourceCache.set(st, out);
895
+ return out;
896
+ }
897
+ return typeof st === "string" ? parseStackLocation(st) : void 0;
802
898
  }
899
+ var sourceCache = /* @__PURE__ */ new WeakMap();
803
900
  function bumpCount(fiber) {
804
901
  const prev = counts.get(fiber) ?? (fiber.alternate ? counts.get(fiber.alternate) : void 0) ?? 0;
805
902
  const n = prev + 1;
@@ -867,7 +964,8 @@ function diffHooks(fiber, alt) {
867
964
  const labels = hookLabelsFor(fiber, b);
868
965
  let i = 0;
869
966
  while (a && b) {
870
- 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)) {
871
969
  const hook = labels?.[i] ?? hookLabel2(b);
872
970
  out.push({
873
971
  path: `${hook}#${i}`,
@@ -901,6 +999,10 @@ function diffContexts(fiber, alt) {
901
999
  let a = alt.dependencies?.firstContext ?? null;
902
1000
  let b = fiber.dependencies?.firstContext ?? null;
903
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
+ }
904
1006
  while (a && b) {
905
1007
  if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
906
1008
  const name = b.context.displayName ?? "Context";
@@ -933,10 +1035,26 @@ function diffContexts(fiber, alt) {
933
1035
  return out;
934
1036
  }
935
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
+ }
936
1054
  function analyze(fiber, alt, trackHooks) {
937
1055
  const prevProps = alt.memoizedProps ?? {};
938
1056
  const nextProps = fiber.memoizedProps ?? {};
939
- const propChanges = prevProps === nextProps ? [] : diffRecords(prevProps, nextProps);
1057
+ const propChanges = diffProps(prevProps, nextProps);
940
1058
  let stateChanges = [];
941
1059
  let hookChanges = [];
942
1060
  if (fiber.tag === ClassComponent) {
@@ -949,7 +1067,7 @@ function analyze(fiber, alt, trackHooks) {
949
1067
  } else if (trackHooks) {
950
1068
  hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
951
1069
  }
952
- const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
1070
+ const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
953
1071
  const causes = [];
954
1072
  if (propChanges.some(isGenuine2)) causes.push("props");
955
1073
  if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
@@ -987,6 +1105,26 @@ function updatersOf(root) {
987
1105
  }
988
1106
  var lastCommit = null;
989
1107
  var EFFECT_LOOP_WINDOW_MS = 50;
1108
+ var MAX_REPORTS_PER_COMMIT = 200;
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
+ }
990
1128
  function onCommit(root, commitPriority) {
991
1129
  const s = getState();
992
1130
  if (!s.enabled) return;
@@ -996,7 +1134,7 @@ function onCommit(root, commitPriority) {
996
1134
  const rendered = [];
997
1135
  const renderedNames = /* @__PURE__ */ new Set();
998
1136
  let hot = false;
999
- let suspenseResolved = false;
1137
+ let resolvedBoundaries = null;
1000
1138
  const stack = [root.current];
1001
1139
  while (stack.length) {
1002
1140
  const fiber = stack.pop();
@@ -1006,7 +1144,7 @@ function onCommit(root, commitPriority) {
1006
1144
  renderedNames.add(fiberName(fiber));
1007
1145
  if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
1008
1146
  }
1009
- 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);
1010
1148
  if (!alt || fiber.child !== alt.child) {
1011
1149
  for (let c = fiber.child; c; c = c.sibling) stack.push(c);
1012
1150
  }
@@ -1017,10 +1155,11 @@ function onCommit(root, commitPriority) {
1017
1155
  const updaters = updatersOf(root);
1018
1156
  let commitCause;
1019
1157
  let afterCommit;
1020
- if (lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
1158
+ const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
1159
+ if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
1021
1160
  commitCause = "effect-after-commit";
1022
1161
  afterCommit = lastCommit.id;
1023
- } else if (suspenseResolved) commitCause = "suspense-resolved";
1162
+ } else if (resolvedBoundaries) commitCause = "suspense-resolved";
1024
1163
  if (rendered.length === 0) {
1025
1164
  lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
1026
1165
  return;
@@ -1029,82 +1168,262 @@ function onCommit(root, commitPriority) {
1029
1168
  lastCommit = { id: commitId, at, rendered: renderedNames };
1030
1169
  const dispatcherRef = o.resolveHookNames ? getDispatcherRef() : null;
1031
1170
  const parentCache = /* @__PURE__ */ new Map();
1032
- for (const fiber of rendered) {
1033
- const alt = fiber.alternate;
1034
- const a = analyze(fiber, alt, trackHooks);
1035
- const durations = durationsOf(fiber);
1036
- let hookState = o.includeState !== false && trackHooks ? snapshotHooks(fiber) : void 0;
1037
- if (dispatcherRef && fiber.tag !== ClassComponent && (a.hookChanges.length || hookState && hookState.length)) {
1038
- const names = resolveHookNames(fiber, dispatcherRef);
1039
- if (names.size) {
1040
- for (const c of a.hookChanges) if (c.hook !== "useContext" && names.has(c.index)) c.custom = names.get(c.index);
1041
- if (hookState) hookState = hookState.map((h) => names.has(h.index) ? { ...h, custom: names.get(h.index) } : h);
1171
+ const endDiffScope = beginDiffScope();
1172
+ const deadline = at + COMMIT_TIME_BUDGET_MS;
1173
+ try {
1174
+ for (let i = 0; i < rendered.length; i++) {
1175
+ if (i >= MAX_REPORTS_PER_COMMIT || (i & 15) === 15 && nowMs() > deadline) {
1176
+ s.truncated += rendered.length - i;
1177
+ warnOnce("truncated", `a commit rendered ${rendered.length} tracked components; only ${i} were reported (see info().truncated). Narrow \`include\` or turn off includeState.`);
1178
+ break;
1179
+ }
1180
+ const fiber = rendered[i];
1181
+ const alt = fiber.alternate;
1182
+ const a = analyze(fiber, alt, trackHooks);
1183
+ const durations = durationsOf(fiber);
1184
+ let hookState = o.includeState !== false && trackHooks ? snapshotHooks(fiber) : void 0;
1185
+ if (dispatcherRef && fiber.tag !== ClassComponent && (a.hookChanges.length || hookState && hookState.length)) {
1186
+ const names = resolveHookNames(fiber, dispatcherRef);
1187
+ if (names.size) {
1188
+ for (const c of a.hookChanges) if (c.hook !== "useContext" && names.has(c.index)) c.custom = names.get(c.index);
1189
+ if (hookState) hookState = hookState.map((h) => names.has(h.index) ? { ...h, custom: names.get(h.index) } : h);
1190
+ }
1042
1191
  }
1192
+ const report = buildReport({
1193
+ component: fiberName(fiber),
1194
+ instanceId: instanceId(fiber),
1195
+ renderCount: bumpCount(fiber),
1196
+ prevProps: alt.memoizedProps ?? {},
1197
+ nextProps: fiber.memoizedProps ?? {},
1198
+ propChanges: a.propChanges,
1199
+ stateChanges: a.stateChanges,
1200
+ hookChanges: a.hookChanges,
1201
+ ...o.includeState !== false ? {
1202
+ hookState,
1203
+ contexts: trackHooks ? snapshotContexts(fiber) : void 0,
1204
+ state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
1205
+ } : {},
1206
+ updaters,
1207
+ commitCause,
1208
+ afterCommit,
1209
+ key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
1210
+ parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
1211
+ owner: ownerName(fiber),
1212
+ path: componentPath(fiber),
1213
+ memoized: isMemoizedFiber(fiber),
1214
+ compiled: isCompiledFiber(fiber),
1215
+ classComponent: fiber.tag === ClassComponent,
1216
+ revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
1217
+ selfDuration: durations ? durations.self : void 0,
1218
+ treeDuration: durations ? durations.tree : void 0,
1219
+ commitId,
1220
+ commitPriority,
1221
+ source: sourceOf(fiber)
1222
+ });
1223
+ dispatch(report);
1043
1224
  }
1044
- const report = buildReport({
1045
- component: fiberName(fiber),
1046
- instanceId: instanceId(fiber),
1047
- renderCount: bumpCount(fiber),
1048
- prevProps: alt.memoizedProps ?? {},
1049
- nextProps: fiber.memoizedProps ?? {},
1050
- propChanges: a.propChanges,
1051
- stateChanges: a.stateChanges,
1052
- hookChanges: a.hookChanges,
1053
- ...o.includeState !== false ? {
1054
- hookState,
1055
- contexts: trackHooks ? snapshotContexts(fiber) : void 0,
1056
- state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
1057
- } : {},
1058
- updaters,
1059
- commitCause,
1060
- afterCommit,
1061
- key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
1062
- parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
1063
- owner: ownerName(fiber),
1064
- path: componentPath(fiber),
1065
- memoized: isMemoizedFiber(fiber),
1066
- selfDuration: durations ? durations.self : void 0,
1067
- treeDuration: durations ? durations.tree : void 0,
1068
- commitId,
1069
- commitPriority,
1070
- source: sourceOf(fiber)
1071
- });
1072
- dispatch(report);
1225
+ } finally {
1226
+ endDiffScope();
1227
+ }
1228
+ }
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);
1073
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");
1074
1311
  }
1075
1312
 
1076
1313
  // src/fixes.ts
1077
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
1314
+ var MAX_FIX_REPORTS = 50;
1078
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
+ }
1079
1327
  function fixesFor(r) {
1080
1328
  const out = [];
1081
- const owner = r.owner || r.parent && r.parent.name || "?";
1082
- 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 || []];
1083
1332
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
1333
+ const identical = changes.length === 0;
1084
1334
  out.push({
1085
1335
  kind: "memo",
1086
1336
  owner: r.component,
1087
1337
  target: r.component,
1088
1338
  prop: null,
1089
1339
  label: `Wrap <${r.component}> in React.memo`,
1090
- 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`
1091
1348
  });
1092
1349
  }
1093
- for (const c of r.propChanges) {
1094
- if (!AVOIDABLE.has(c.kind)) continue;
1350
+ for (const c of propChanges) {
1351
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1352
+ const owner = ownerName2 || "?";
1095
1353
  const root = rootOf(c.path);
1354
+ const id = identifier(root);
1096
1355
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
1097
- 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
+ });
1098
1375
  } else if (c.kind === "function") {
1099
- 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
+ });
1100
1392
  } else if (c.kind === "element") {
1101
- 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
+ });
1102
1406
  } else {
1103
- 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
+ });
1104
1423
  }
1105
1424
  }
1106
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
1107
- const isContext = c.hook === "useContext";
1425
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
1426
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
1108
1427
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
1109
1428
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
1110
1429
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -1114,20 +1433,63 @@ function fixesFor(r) {
1114
1433
  target: r.component,
1115
1434
  prop: ctxName,
1116
1435
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
1117
- 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]});`
1118
1444
  });
1119
1445
  continue;
1120
1446
  }
1121
- if (!AVOIDABLE.has(c.kind)) continue;
1447
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1122
1448
  if (isContext) {
1123
- 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
+ });
1124
1460
  } else if (c.hook === "useSyncExternalStore") {
1125
1461
  const chain = c.custom || [];
1126
1462
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
1127
1463
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
1128
- 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
+ });
1129
1482
  } else {
1130
- 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
+ });
1131
1493
  }
1132
1494
  }
1133
1495
  return out;
@@ -1141,11 +1503,12 @@ function rankFixes(reports) {
1141
1503
  const key = fixKey(f);
1142
1504
  let agg = byKey.get(key);
1143
1505
  if (!agg) {
1144
- agg = { ...f, key, count: 0, components: {} };
1506
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
1145
1507
  byKey.set(key, agg);
1146
1508
  }
1147
1509
  agg.count++;
1148
1510
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
1511
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
1149
1512
  }
1150
1513
  }
1151
1514
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -1155,6 +1518,8 @@ function formatFixes(reports, limit = 10) {
1155
1518
  if (!ranked.length) return "No avoidable re-renders.";
1156
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(", ")})`);
1157
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);
1158
1523
  return lines.join("\n");
1159
1524
  }
1160
1525
 
@@ -1253,7 +1618,7 @@ ${formatFixes(reports)}`);
1253
1618
  };
1254
1619
  }
1255
1620
 
1256
- // src/vitest.ts
1621
+ // src/runner-report.ts
1257
1622
  var OUT_ENV = "RERENDER_LENS_OUT";
1258
1623
  function setupRerenderLens(options = {}, hooks = {}) {
1259
1624
  ensureDevtoolsHook();