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
package/dist/vitest.cjs CHANGED
@@ -6,7 +6,7 @@ var fs = require('fs');
6
6
  var os = require('os');
7
7
  var path = require('path');
8
8
 
9
- // src/vitest.ts
9
+ // src/runner-report.ts
10
10
 
11
11
  // src/diff.ts
12
12
  var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
@@ -31,10 +31,49 @@ function remember(seen, a, b) {
31
31
  set.add(b);
32
32
  return false;
33
33
  }
34
+ var scope = null;
35
+ var DEFAULT_DIFF_BUDGET = 5e4;
36
+ function beginDiffScope(budget = 2e5) {
37
+ const previous = scope;
38
+ scope = { cache: /* @__PURE__ */ new WeakMap(), remaining: budget, exhausted: false };
39
+ return () => {
40
+ scope = previous;
41
+ };
42
+ }
43
+ function diffBudgetExhausted() {
44
+ return scope?.exhausted === true;
45
+ }
34
46
  function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
35
47
  if (Object.is(a, b)) return true;
36
48
  if (typeof a !== typeof b) return false;
37
49
  if (typeof a !== "object" || a === null || b === null) return false;
50
+ if (!scope) {
51
+ const end = beginDiffScope(DEFAULT_DIFF_BUDGET);
52
+ try {
53
+ return deepEqual(a, b, seen);
54
+ } finally {
55
+ end();
56
+ }
57
+ }
58
+ const sc = scope;
59
+ const objA = a;
60
+ const objB = b;
61
+ const hit = sc.cache.get(objA)?.get(objB);
62
+ if (hit !== void 0) return hit;
63
+ if (--sc.remaining < 0) {
64
+ sc.exhausted = true;
65
+ return false;
66
+ }
67
+ const result = deepEqualObjects(objA, objB, seen);
68
+ let m = sc.cache.get(objA);
69
+ if (!m) {
70
+ m = /* @__PURE__ */ new WeakMap();
71
+ sc.cache.set(objA, m);
72
+ }
73
+ m.set(objB, result);
74
+ return result;
75
+ }
76
+ function deepEqualObjects(a, b, seen) {
38
77
  const objA = a;
39
78
  const objB = b;
40
79
  if (remember(seen, objA, objB)) return true;
@@ -63,6 +102,10 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
63
102
  }
64
103
  if (a instanceof Set) {
65
104
  if (!(b instanceof Set) || a.size !== b.size) return false;
105
+ if (a.size > 50) {
106
+ for (const v of a) if (!b.has(v)) return false;
107
+ return true;
108
+ }
66
109
  outer: for (const v of a) {
67
110
  if (b.has(v)) continue;
68
111
  for (const w of b) if (deepEqual(v, w, seen)) continue outer;
@@ -78,14 +121,16 @@ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
78
121
  const ka = Object.keys(a);
79
122
  const kb = Object.keys(b);
80
123
  if (ka.length !== kb.length) return false;
124
+ const rb = b;
81
125
  for (const k of ka) {
82
126
  if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
83
- if (!deepEqual(a[k], b[k], seen)) return false;
127
+ if (!deepEqual(a[k], rb[k], seen)) return false;
84
128
  }
85
129
  return true;
86
130
  }
87
131
  return false;
88
132
  }
133
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
89
134
  function classify(prev, next) {
90
135
  if (typeof prev === "function" && typeof next === "function") {
91
136
  return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
@@ -122,30 +167,30 @@ function diffRecords(prev, next, basePath = "") {
122
167
  if (Object.is(a, b)) continue;
123
168
  const kind = classify(a, b);
124
169
  if (kind === "different") {
125
- changes.push({ path: firstDifferentPath(a, b, path), kind, prev: a, next: b });
170
+ changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
126
171
  } else {
127
172
  changes.push({ path, kind, prev: a, next: b });
128
173
  }
129
174
  }
130
175
  return changes;
131
176
  }
132
- function firstDifferentPath(a, b, path, depth = 0) {
133
- if (depth > 8) return path;
177
+ function firstDifferentPath(a, b, path = "", depth = 0) {
178
+ if (depth > 8) return path || "(value)";
134
179
  if (Array.isArray(a) && Array.isArray(b)) {
135
- if (a.length !== b.length) return `${path}.length`;
180
+ if (a.length !== b.length) return path ? `${path}.length` : "length";
136
181
  for (let i = 0; i < a.length; i++) {
137
182
  if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
138
183
  }
139
- return path;
184
+ return null;
140
185
  }
141
186
  if (isPlainObject(a) && isPlainObject(b)) {
142
187
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
143
188
  for (const k of keys) {
144
189
  if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
145
190
  }
146
- return path;
191
+ return null;
147
192
  }
148
- return path;
193
+ return deepEqual(a, b) ? null : path || "(value)";
149
194
  }
150
195
 
151
196
  // src/report.ts
@@ -163,10 +208,12 @@ function storeAdvice(custom) {
163
208
  }
164
209
  var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
165
210
  var isChildren = (change) => change.path === "children";
211
+ var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
166
212
  function fixFor(change) {
167
213
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
168
214
  return `lift the children out of the parent's render: memoize them with useMemo, hoist static elements to module scope, or render them from a component that does not re-render`;
169
215
  }
216
+ if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
170
217
  switch (change.kind) {
171
218
  case "function":
172
219
  return `wrap it in useCallback (or hoist it out of the parent's render)`;
@@ -182,6 +229,7 @@ function describe(change) {
182
229
  if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
183
230
  return `children are new React elements with the same types and props on every render of the parent`;
184
231
  }
232
+ if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
185
233
  switch (change.kind) {
186
234
  case "deep-equal":
187
235
  return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
@@ -209,11 +257,21 @@ function buildReport(input) {
209
257
  if (causes.length === 0) trigger = "parent";
210
258
  else if (causes.length === 1) trigger = causes[0];
211
259
  else trigger = "mixed";
212
- const avoidable = trigger === "parent";
260
+ const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
261
+ const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
262
+ const revealed = input.revealed === true && trigger === "parent";
263
+ const avoidable = trigger === "parent" && !cacheHit && !revealed;
213
264
  const reasons = [];
214
- if (avoidable && input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0) {
265
+ const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
266
+ if (cacheHit) {
267
+ reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
268
+ } else if (revealed) {
269
+ reasons.push(
270
+ `re-rendered because the Suspense boundary above it switched from its fallback to content: React re-renders the content it kept hidden while the fallback was shown. A one-off; nothing to fix.`
271
+ );
272
+ } else if (avoidable && nothingChanged) {
215
273
  const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
216
- reasons.push(`re-rendered with identical props because ${who}. Wrap "${input.component}" in React.memo (or extend PureComponent).`);
274
+ reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
217
275
  } else if (avoidable && input.parent) {
218
276
  reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
219
277
  }
@@ -222,9 +280,9 @@ function buildReport(input) {
222
280
  reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
223
281
  }
224
282
  const memoized = input.memoized !== false;
225
- if (avoidable && !memoized && input.propChanges.length > 0) {
283
+ if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
226
284
  reasons.push(
227
- `"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also wrap it in React.memo (or extend PureComponent).`
285
+ `"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also ${input.classComponent ? "extend PureComponent (or implement shouldComponentUpdate)" : "wrap it in React.memo (or extend PureComponent)"}.`
228
286
  );
229
287
  }
230
288
  for (const c of stateChanges) {
@@ -236,6 +294,8 @@ function buildReport(input) {
236
294
  const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
237
295
  const keys = c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys ? `: only ${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed, yet every consumer re-renders. Split the context or memoize the slices consumers read` : "";
238
296
  reasons.push(`${c.path} changed${where}${keys}.`);
297
+ } else if (c.hook === "useDeferredValue" && isGenuine(c)) {
298
+ reasons.push(`${hookLabel(c)} caught up with its latest value: this is the deferred second render React schedules after the urgent one; nothing to fix.`);
239
299
  } else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
240
300
  else if (isStateHook(c))
241
301
  reasons.push(`${hookLabel(c)} was set to a value deep-equal to the current one (new reference, same contents): reuse the existing object or bail out before calling the setter.`);
@@ -266,6 +326,7 @@ function buildReport(input) {
266
326
  reasons,
267
327
  time: now()
268
328
  };
329
+ if (input.compiled) report.compiled = true;
269
330
  if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
270
331
  if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
271
332
  if (input.commitPriority) report.commitPriority = input.commitPriority;
@@ -303,10 +364,10 @@ var KIND_LABEL = {
303
364
  };
304
365
  function summarize(report) {
305
366
  const counts2 = /* @__PURE__ */ new Map();
306
- for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
367
+ for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
307
368
  counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
308
369
  }
309
- const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
370
+ const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
310
371
  if (parts.length === 0) parts.push("no changes");
311
372
  return parts.join(", ");
312
373
  }
@@ -333,7 +394,7 @@ function getState() {
333
394
  const g = globalThis;
334
395
  let s = g[KEY];
335
396
  if (!s) {
336
- 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 };
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 };
337
398
  g[KEY] = s;
338
399
  }
339
400
  return s;
@@ -363,7 +424,7 @@ function dispatch(report, override) {
363
424
  try {
364
425
  options.notifier(report);
365
426
  } catch (err) {
366
- (options.console ?? console).warn("[rerender-lens] notifier threw", err);
427
+ warnOnce("notifier", `notifier threw: ${String(err)}`);
367
428
  }
368
429
  }
369
430
  }
@@ -430,6 +491,27 @@ function disable() {
430
491
  }
431
492
 
432
493
  // src/hookNames.ts
494
+ var NODE_COUNT = {
495
+ useState: 1,
496
+ useReducer: 1,
497
+ useRef: 1,
498
+ useMemo: 1,
499
+ useCallback: 1,
500
+ useEffect: 1,
501
+ useLayoutEffect: 1,
502
+ useInsertionEffect: 1,
503
+ useImperativeHandle: 1,
504
+ useSyncExternalStore: 2,
505
+ useTransition: 2,
506
+ useDeferredValue: 1,
507
+ useId: 1,
508
+ useOptimistic: 1,
509
+ useActionState: 3,
510
+ useFormState: 3,
511
+ useEffectEvent: 1,
512
+ useCacheRefresh: 1
513
+ };
514
+ var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
433
515
  var noop = () => {
434
516
  };
435
517
  function customHooksFromStack(stack, componentName, marker) {
@@ -444,7 +526,7 @@ function customHooksFromStack(stack, componentName, marker) {
444
526
  continue;
445
527
  }
446
528
  if (name === componentName || name.endsWith("." + componentName)) break;
447
- if (/^use[A-Z0-9_]/.test(name)) names.push(name);
529
+ if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
448
530
  if (names.length > 8) break;
449
531
  }
450
532
  return names;
@@ -728,6 +810,11 @@ function isMemoizedFiber(fiber) {
728
810
  }
729
811
  return false;
730
812
  }
813
+ function isCompiledFiber(fiber) {
814
+ if (fiber.tag === ClassComponent) return false;
815
+ const cache2 = fiber.updateQueue?.memoCache;
816
+ return !!cache2 && Array.isArray(cache2.data);
817
+ }
731
818
  function durationsOf(fiber) {
732
819
  if (typeof fiber.actualDuration !== "number") return null;
733
820
  const tree = fiber.actualDuration;
@@ -751,11 +838,14 @@ var counts = /* @__PURE__ */ new WeakMap();
751
838
  var ids = /* @__PURE__ */ new WeakMap();
752
839
  var fibersById = /* @__PURE__ */ new Map();
753
840
  var hasWeakRef = typeof WeakRef === "function";
841
+ var pruneAt = 5e3;
754
842
  function remember2(id, fiber) {
755
843
  if (!hasWeakRef) return;
844
+ if (fibersById.get(id)?.deref() === fiber) return;
756
845
  fibersById.set(id, new WeakRef(fiber));
757
- if (fibersById.size > 5e3) {
846
+ if (fibersById.size > pruneAt) {
758
847
  for (const [k, ref] of fibersById) if (!ref.deref()) fibersById.delete(k);
848
+ pruneAt = Math.max(5e3, fibersById.size * 2);
759
849
  }
760
850
  }
761
851
  function instanceIdOf(fiber) {
@@ -784,7 +874,7 @@ function nearestComponent(fiber) {
784
874
  }
785
875
  var INTERNAL_FRAME = /react-dom|react_jsx|jsx-(dev-)?runtime|\/react\/|node_modules\/react|react-stack-bottom-frame|react_stack_bottom_frame|scheduler/;
786
876
  function parseStackLocation(stack) {
787
- for (const line of stack.split("\n")) {
877
+ for (const line of stack.split("\n", 24)) {
788
878
  const m = /(?:at\s+(?:.*?\s+)?\(?|@)?((?:https?|file|webpack|vite|blob):[^\s()]+?):(\d+):(\d+)\)?\s*$/.exec(line.trim());
789
879
  if (!m || !m[1] || INTERNAL_FRAME.test(m[1])) continue;
790
880
  return { fileName: m[1], lineNumber: Number(m[2]), columnNumber: Number(m[3]) };
@@ -800,9 +890,16 @@ function sourceOf(fiber) {
800
890
  return out;
801
891
  }
802
892
  const st = fiber._debugStack;
803
- const text = typeof st === "string" ? st : st && typeof st.stack === "string" ? st.stack : null;
804
- return text ? parseStackLocation(text) : void 0;
893
+ if (st && typeof st === "object") {
894
+ if (sourceCache.has(st)) return sourceCache.get(st);
895
+ const text = typeof st.stack === "string" ? st.stack : null;
896
+ const out = text ? parseStackLocation(text) : void 0;
897
+ sourceCache.set(st, out);
898
+ return out;
899
+ }
900
+ return typeof st === "string" ? parseStackLocation(st) : void 0;
805
901
  }
902
+ var sourceCache = /* @__PURE__ */ new WeakMap();
806
903
  function bumpCount(fiber) {
807
904
  const prev = counts.get(fiber) ?? (fiber.alternate ? counts.get(fiber.alternate) : void 0) ?? 0;
808
905
  const n = prev + 1;
@@ -870,7 +967,8 @@ function diffHooks(fiber, alt) {
870
967
  const labels = hookLabelsFor(fiber, b);
871
968
  let i = 0;
872
969
  while (a && b) {
873
- if ((isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
970
+ const deferred = labels?.[i] === "useDeferredValue";
971
+ if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
874
972
  const hook = labels?.[i] ?? hookLabel2(b);
875
973
  out.push({
876
974
  path: `${hook}#${i}`,
@@ -904,6 +1002,10 @@ function diffContexts(fiber, alt) {
904
1002
  let a = alt.dependencies?.firstContext ?? null;
905
1003
  let b = fiber.dependencies?.firstContext ?? null;
906
1004
  let i = 0;
1005
+ if (b && !("memoizedValue" in b)) {
1006
+ warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
1007
+ return out;
1008
+ }
907
1009
  while (a && b) {
908
1010
  if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
909
1011
  const name = b.context.displayName ?? "Context";
@@ -936,10 +1038,26 @@ function diffContexts(fiber, alt) {
936
1038
  return out;
937
1039
  }
938
1040
  var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
1041
+ var isRefObject2 = (v) => {
1042
+ if (typeof v !== "object" || v === null) return false;
1043
+ const keys = Object.keys(v);
1044
+ return keys.length === 1 && keys[0] === "current";
1045
+ };
1046
+ function diffProps(prev, next) {
1047
+ if (prev === next) return [];
1048
+ const pr = prev.ref;
1049
+ const nr = next.ref;
1050
+ if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
1051
+ const { ref: _p, ...restPrev } = prev;
1052
+ const { ref: _n, ...restNext } = next;
1053
+ return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
1054
+ }
1055
+ return diffRecords(prev, next);
1056
+ }
939
1057
  function analyze(fiber, alt, trackHooks) {
940
1058
  const prevProps = alt.memoizedProps ?? {};
941
1059
  const nextProps = fiber.memoizedProps ?? {};
942
- const propChanges = prevProps === nextProps ? [] : diffRecords(prevProps, nextProps);
1060
+ const propChanges = diffProps(prevProps, nextProps);
943
1061
  let stateChanges = [];
944
1062
  let hookChanges = [];
945
1063
  if (fiber.tag === ClassComponent) {
@@ -952,7 +1070,7 @@ function analyze(fiber, alt, trackHooks) {
952
1070
  } else if (trackHooks) {
953
1071
  hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
954
1072
  }
955
- const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
1073
+ const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
956
1074
  const causes = [];
957
1075
  if (propChanges.some(isGenuine2)) causes.push("props");
958
1076
  if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
@@ -990,6 +1108,26 @@ function updatersOf(root) {
990
1108
  }
991
1109
  var lastCommit = null;
992
1110
  var EFFECT_LOOP_WINDOW_MS = 50;
1111
+ var MAX_REPORTS_PER_COMMIT = 200;
1112
+ var COMMIT_TIME_BUDGET_MS = 25;
1113
+ function isDeferredCatchUp(fiber) {
1114
+ const types = fiber._debugHookTypes;
1115
+ if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
1116
+ const first = fiber.memoizedState;
1117
+ if (!isHookList(first)) return false;
1118
+ const labels = hookLabelsFor(fiber, first);
1119
+ if (!labels) return false;
1120
+ let a = fiber.alternate.memoizedState;
1121
+ let b = first;
1122
+ for (let i = 0; a && b; a = a.next, b = b.next, i++) {
1123
+ if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
1124
+ }
1125
+ return false;
1126
+ }
1127
+ function isUnder(fiber, boundaries) {
1128
+ for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
1129
+ return false;
1130
+ }
993
1131
  function onCommit(root, commitPriority) {
994
1132
  const s = getState();
995
1133
  if (!s.enabled) return;
@@ -999,7 +1137,7 @@ function onCommit(root, commitPriority) {
999
1137
  const rendered = [];
1000
1138
  const renderedNames = /* @__PURE__ */ new Set();
1001
1139
  let hot = false;
1002
- let suspenseResolved = false;
1140
+ let resolvedBoundaries = null;
1003
1141
  const stack = [root.current];
1004
1142
  while (stack.length) {
1005
1143
  const fiber = stack.pop();
@@ -1009,7 +1147,7 @@ function onCommit(root, commitPriority) {
1009
1147
  renderedNames.add(fiberName(fiber));
1010
1148
  if (shouldTrack(fiberType(fiber), o)) rendered.push(fiber);
1011
1149
  }
1012
- if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) suspenseResolved = true;
1150
+ if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
1013
1151
  if (!alt || fiber.child !== alt.child) {
1014
1152
  for (let c = fiber.child; c; c = c.sibling) stack.push(c);
1015
1153
  }
@@ -1020,10 +1158,11 @@ function onCommit(root, commitPriority) {
1020
1158
  const updaters = updatersOf(root);
1021
1159
  let commitCause;
1022
1160
  let afterCommit;
1023
- if (lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
1161
+ const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
1162
+ if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
1024
1163
  commitCause = "effect-after-commit";
1025
1164
  afterCommit = lastCommit.id;
1026
- } else if (suspenseResolved) commitCause = "suspense-resolved";
1165
+ } else if (resolvedBoundaries) commitCause = "suspense-resolved";
1027
1166
  if (rendered.length === 0) {
1028
1167
  lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
1029
1168
  return;
@@ -1032,82 +1171,262 @@ function onCommit(root, commitPriority) {
1032
1171
  lastCommit = { id: commitId, at, rendered: renderedNames };
1033
1172
  const dispatcherRef = o.resolveHookNames ? getDispatcherRef() : null;
1034
1173
  const parentCache = /* @__PURE__ */ new Map();
1035
- for (const fiber of rendered) {
1036
- const alt = fiber.alternate;
1037
- const a = analyze(fiber, alt, trackHooks);
1038
- const durations = durationsOf(fiber);
1039
- let hookState = o.includeState !== false && trackHooks ? snapshotHooks(fiber) : void 0;
1040
- if (dispatcherRef && fiber.tag !== ClassComponent && (a.hookChanges.length || hookState && hookState.length)) {
1041
- const names = resolveHookNames(fiber, dispatcherRef);
1042
- if (names.size) {
1043
- for (const c of a.hookChanges) if (c.hook !== "useContext" && names.has(c.index)) c.custom = names.get(c.index);
1044
- if (hookState) hookState = hookState.map((h) => names.has(h.index) ? { ...h, custom: names.get(h.index) } : h);
1174
+ const endDiffScope = beginDiffScope();
1175
+ const deadline = at + COMMIT_TIME_BUDGET_MS;
1176
+ try {
1177
+ for (let i = 0; i < rendered.length; i++) {
1178
+ if (i >= MAX_REPORTS_PER_COMMIT || (i & 15) === 15 && nowMs() > deadline) {
1179
+ s.truncated += rendered.length - i;
1180
+ warnOnce("truncated", `a commit rendered ${rendered.length} tracked components; only ${i} were reported (see info().truncated). Narrow \`include\` or turn off includeState.`);
1181
+ break;
1182
+ }
1183
+ const fiber = rendered[i];
1184
+ const alt = fiber.alternate;
1185
+ const a = analyze(fiber, alt, trackHooks);
1186
+ const durations = durationsOf(fiber);
1187
+ let hookState = o.includeState !== false && trackHooks ? snapshotHooks(fiber) : void 0;
1188
+ if (dispatcherRef && fiber.tag !== ClassComponent && (a.hookChanges.length || hookState && hookState.length)) {
1189
+ const names = resolveHookNames(fiber, dispatcherRef);
1190
+ if (names.size) {
1191
+ for (const c of a.hookChanges) if (c.hook !== "useContext" && names.has(c.index)) c.custom = names.get(c.index);
1192
+ if (hookState) hookState = hookState.map((h) => names.has(h.index) ? { ...h, custom: names.get(h.index) } : h);
1193
+ }
1045
1194
  }
1195
+ const report = buildReport({
1196
+ component: fiberName(fiber),
1197
+ instanceId: instanceId(fiber),
1198
+ renderCount: bumpCount(fiber),
1199
+ prevProps: alt.memoizedProps ?? {},
1200
+ nextProps: fiber.memoizedProps ?? {},
1201
+ propChanges: a.propChanges,
1202
+ stateChanges: a.stateChanges,
1203
+ hookChanges: a.hookChanges,
1204
+ ...o.includeState !== false ? {
1205
+ hookState,
1206
+ contexts: trackHooks ? snapshotContexts(fiber) : void 0,
1207
+ state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
1208
+ } : {},
1209
+ updaters,
1210
+ commitCause,
1211
+ afterCommit,
1212
+ key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
1213
+ parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
1214
+ owner: ownerName(fiber),
1215
+ path: componentPath(fiber),
1216
+ memoized: isMemoizedFiber(fiber),
1217
+ compiled: isCompiledFiber(fiber),
1218
+ classComponent: fiber.tag === ClassComponent,
1219
+ revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
1220
+ selfDuration: durations ? durations.self : void 0,
1221
+ treeDuration: durations ? durations.tree : void 0,
1222
+ commitId,
1223
+ commitPriority,
1224
+ source: sourceOf(fiber)
1225
+ });
1226
+ dispatch(report);
1046
1227
  }
1047
- const report = buildReport({
1048
- component: fiberName(fiber),
1049
- instanceId: instanceId(fiber),
1050
- renderCount: bumpCount(fiber),
1051
- prevProps: alt.memoizedProps ?? {},
1052
- nextProps: fiber.memoizedProps ?? {},
1053
- propChanges: a.propChanges,
1054
- stateChanges: a.stateChanges,
1055
- hookChanges: a.hookChanges,
1056
- ...o.includeState !== false ? {
1057
- hookState,
1058
- contexts: trackHooks ? snapshotContexts(fiber) : void 0,
1059
- state: fiber.tag === ClassComponent && fiber.memoizedState && typeof fiber.memoizedState === "object" ? fiber.memoizedState : void 0
1060
- } : {},
1061
- updaters,
1062
- commitCause,
1063
- afterCommit,
1064
- key: fiber.key === null || fiber.key === void 0 ? null : String(fiber.key),
1065
- parent: a.trigger === "parent" ? nearestRenderedAncestor(fiber, parentCache, trackHooks) : null,
1066
- owner: ownerName(fiber),
1067
- path: componentPath(fiber),
1068
- memoized: isMemoizedFiber(fiber),
1069
- selfDuration: durations ? durations.self : void 0,
1070
- treeDuration: durations ? durations.tree : void 0,
1071
- commitId,
1072
- commitPriority,
1073
- source: sourceOf(fiber)
1074
- });
1075
- dispatch(report);
1228
+ } finally {
1229
+ endDiffScope();
1076
1230
  }
1077
1231
  }
1078
1232
 
1233
+ // src/causes.ts
1234
+ var isAncestorReport = (anc, r) => anc.path.length < r.path.length && r.path[anc.path.length] === anc.component && anc.path.every((p, i) => r.path[i] === p);
1235
+ var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
1236
+ function indexByComponent(reports) {
1237
+ const index = /* @__PURE__ */ new Map();
1238
+ for (const r of reports) {
1239
+ const list = index.get(r.component);
1240
+ if (list) list.push(r);
1241
+ else index.set(r.component, [r]);
1242
+ }
1243
+ return index;
1244
+ }
1245
+ function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
1246
+ let cur = r;
1247
+ const seen = /* @__PURE__ */ new Set([r]);
1248
+ while (cur.trigger === "parent" && cur.parent) {
1249
+ const parent = cur.parent;
1250
+ const candidates = index.get(parent.name);
1251
+ const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
1252
+ if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
1253
+ seen.add(p);
1254
+ cur = p;
1255
+ }
1256
+ return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
1257
+ }
1258
+ function rootCausesOf(reports) {
1259
+ const roots = /* @__PURE__ */ new Map();
1260
+ const rootByReport = /* @__PURE__ */ new Map();
1261
+ const index = indexByComponent(reports);
1262
+ let avoidable = 0;
1263
+ let wasted = 0;
1264
+ for (const r of reports) {
1265
+ if (!r.avoidable) continue;
1266
+ avoidable++;
1267
+ if (typeof r.selfDuration === "number") wasted += r.selfDuration;
1268
+ const root = rootCauseOf(r, reports, index);
1269
+ const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
1270
+ const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
1271
+ rootByReport.set(r, name);
1272
+ let agg = roots.get(name);
1273
+ if (!agg) {
1274
+ agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
1275
+ roots.set(name, agg);
1276
+ }
1277
+ agg.count++;
1278
+ bump(agg.components, r.component);
1279
+ }
1280
+ return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
1281
+ }
1282
+ function groupByCommit(reports) {
1283
+ const out = /* @__PURE__ */ new Map();
1284
+ for (const r of reports) {
1285
+ if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
1286
+ const list = out.get(r.commitId);
1287
+ if (list) list.push(r);
1288
+ else out.set(r.commitId, [r]);
1289
+ }
1290
+ return out;
1291
+ }
1292
+ function rankRootCauses(reports) {
1293
+ const byName = /* @__PURE__ */ new Map();
1294
+ for (const commit of groupByCommit(reports).values()) {
1295
+ for (const root of rootCausesOf(commit).roots) {
1296
+ let agg = byName.get(root.name);
1297
+ if (!agg) {
1298
+ agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
1299
+ byName.set(root.name, agg);
1300
+ }
1301
+ agg.commits++;
1302
+ agg.count += root.count;
1303
+ for (const [c, n] of root.components) bump(agg.components, c, n);
1304
+ }
1305
+ }
1306
+ return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
1307
+ }
1308
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1309
+ function formatRootCauses(reports, limit = 5) {
1310
+ const ranked = rankRootCauses(reports);
1311
+ const lines = ranked.slice(0, limit).map((root, i) => `${String(i + 1).padStart(2)}. <${root.name}> (${root.trigger}) started ${plural(root.commits, "commit")}, ${plural(root.count, "avoidable re-render")} (${[...root.components].map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
1312
+ if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
1313
+ return lines.join("\n");
1314
+ }
1315
+
1079
1316
  // src/fixes.ts
1080
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
1317
+ var MAX_FIX_REPORTS = 50;
1081
1318
  var rootOf = (path) => path.split(/[.[]/)[0] || path;
1319
+ var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
1320
+ function shortValue(v, max = 60) {
1321
+ let s;
1322
+ try {
1323
+ s = JSON.stringify(v);
1324
+ } catch {
1325
+ s = String(v);
1326
+ }
1327
+ if (s === void 0) s = String(v);
1328
+ return s.length > max ? s.slice(0, max - 3) + "..." : s;
1329
+ }
1082
1330
  function fixesFor(r) {
1083
1331
  const out = [];
1084
- const owner = r.owner || r.parent && r.parent.name || "?";
1085
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
1332
+ const ownerName2 = r.owner || r.parent && r.parent.name || null;
1333
+ const propChanges = r.propChanges || [];
1334
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
1086
1335
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
1336
+ const identical = changes.length === 0;
1087
1337
  out.push({
1088
1338
  kind: "memo",
1089
1339
  owner: r.component,
1090
1340
  target: r.component,
1091
1341
  prop: null,
1092
1342
  label: `Wrap <${r.component}> in React.memo`,
1093
- detail: changes.length === 0 ? `<${r.component}> re-rendered with identical props because <${r.parent && r.parent.name || "its parent"}> re-rendered.` : `<${r.component}> is not memoized: fixing its props alone will not stop the re-render.`
1343
+ detail: identical ? `<${r.component}> re-rendered with identical props because <${r.parent && r.parent.name || "its parent"}> re-rendered.` : `<${r.component}> is not memoized: fixing its props alone will not stop the re-render.`,
1344
+ snippet: `// ${r.component}
1345
+ import { memo } from 'react';
1346
+
1347
+ export const ${r.component} = memo(function ${r.component}(props) {
1348
+ // ...
1349
+ });
1350
+ // class components: extend PureComponent instead`
1094
1351
  });
1095
1352
  }
1096
- for (const c of r.propChanges) {
1097
- if (!AVOIDABLE.has(c.kind)) continue;
1353
+ for (const c of propChanges) {
1354
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1355
+ const owner = ownerName2 || "?";
1098
1356
  const root = rootOf(c.path);
1357
+ const id = identifier(root);
1099
1358
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
1100
- out.push({ kind: "children", owner, target: r.component, prop: "children", label: `memoize children of <${r.component}> in <${owner}>`, detail: `<${owner}> re-creates the children of <${r.component}> on every render.` });
1359
+ out.push({
1360
+ kind: "children",
1361
+ owner,
1362
+ target: r.component,
1363
+ prop: "children",
1364
+ label: `memoize children of <${r.component}> in <${owner}>`,
1365
+ detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
1366
+ snippet: `// ${owner}
1367
+ import { useMemo } from 'react';
1368
+
1369
+ const children = useMemo(() => (
1370
+ <>{/* the same elements */}</>
1371
+ ), [/* deps */]);
1372
+
1373
+ <${r.component}>{children}</${r.component}>
1374
+
1375
+ // static children: hoist them to module scope
1376
+ const STATIC = <em>hi</em>;`
1377
+ });
1101
1378
  } else if (c.kind === "function") {
1102
- out.push({ kind: "useCallback", owner, target: r.component, prop: root, label: `useCallback(${root}) in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.` });
1379
+ out.push({
1380
+ kind: "useCallback",
1381
+ owner,
1382
+ target: r.component,
1383
+ prop: root,
1384
+ label: `useCallback(${root}) in <${owner}>`,
1385
+ detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
1386
+ snippet: `// ${owner}
1387
+ import { useCallback } from 'react';
1388
+
1389
+ const ${id} = useCallback((/* args */) => {
1390
+ // ...
1391
+ }, [/* deps */]);
1392
+
1393
+ <${r.component} ${root}={${id}} />`
1394
+ });
1103
1395
  } else if (c.kind === "element") {
1104
- out.push({ kind: "useMemoElement", owner, target: r.component, prop: root, label: `memoize element prop ${root} in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.` });
1396
+ out.push({
1397
+ kind: "useMemoElement",
1398
+ owner,
1399
+ target: r.component,
1400
+ prop: root,
1401
+ label: `memoize element prop ${root} in <${owner}>`,
1402
+ detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
1403
+ snippet: `// ${owner}
1404
+ import { useMemo } from 'react';
1405
+
1406
+ const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
1407
+ // or pass it as children from a component that does not re-render`
1408
+ });
1105
1409
  } else {
1106
- out.push({ kind: "useMemo", owner, target: r.component, prop: root, label: `useMemo(${root}) in <${owner}>`, detail: `prop "${c.path}" of <${r.component}> is a new ${Array.isArray(c.next) ? "array" : "object"} with the same contents on every render of <${owner}>.` });
1410
+ const isArray = Array.isArray(c.next);
1411
+ out.push({
1412
+ kind: "useMemo",
1413
+ owner,
1414
+ target: r.component,
1415
+ prop: root,
1416
+ label: `useMemo(${root}) in <${owner}>`,
1417
+ detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
1418
+ snippet: `// ${owner}
1419
+ import { useMemo } from 'react';
1420
+
1421
+ const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
1422
+
1423
+ // or, when it never changes, hoist it to module scope:
1424
+ const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
1425
+ });
1107
1426
  }
1108
1427
  }
1109
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
1110
- const isContext = c.hook === "useContext";
1428
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
1429
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
1111
1430
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
1112
1431
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
1113
1432
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -1117,20 +1436,63 @@ function fixesFor(r) {
1117
1436
  target: r.component,
1118
1437
  prop: ctxName,
1119
1438
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
1120
- detail: `${c.changedKeys.length} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer re-rendered.`
1439
+ detail: `${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer (like <${r.component}>) re-rendered. Consumers that read the other keys re-render for nothing.`,
1440
+ snippet: `// ${providerOwner || "Provider"}
1441
+ // one context per independently-changing slice
1442
+ const ${identifier(ctxName)}Static = createContext(...);
1443
+ const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
1444
+
1445
+ // or keep one context and let consumers select a slice:
1446
+ const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
1121
1447
  });
1122
1448
  continue;
1123
1449
  }
1124
- if (!AVOIDABLE.has(c.kind)) continue;
1450
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
1125
1451
  if (isContext) {
1126
- out.push({ kind: "contextValue", owner: providerOwner || `${ctxName}.Provider`, target: r.component, prop: ctxName, label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`, detail: `${ctxName} produced a new value that is deep-equal to the previous one.` });
1452
+ out.push({
1453
+ kind: "contextValue",
1454
+ owner: providerOwner || `${ctxName}.Provider`,
1455
+ target: r.component,
1456
+ prop: ctxName,
1457
+ label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
1458
+ detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
1459
+ snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
1460
+ const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
1461
+ <${ctxName}.Provider value={value}>`
1462
+ });
1127
1463
  } else if (c.hook === "useSyncExternalStore") {
1128
1464
  const chain = c.custom || [];
1129
1465
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
1130
1466
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
1131
- out.push({ kind: "storeSnapshot", owner: r.component, target: r.component, prop: c.path, label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`, detail: storeAdvice(c.custom) });
1467
+ const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
1468
+ out.push({
1469
+ kind: "storeSnapshot",
1470
+ owner: r.component,
1471
+ target: r.component,
1472
+ prop: c.path,
1473
+ label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
1474
+ detail: redux ? `the selector${via} returns a new object on every call, so the component re-renders on every store change.` : zustand ? `the store selector${via} returns a new object on every call, so the component re-renders on every store change.` : `${c.path}${via} returned a new reference with the same contents; getSnapshot must return a cached value.`,
1475
+ snippet: redux ? `// ${r.component}
1476
+ import { shallowEqual } from 'react-redux';
1477
+ const slice = useSelector(selectSlice, shallowEqual);
1478
+ // or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
1479
+ import { useShallow } from 'zustand/react/shallow';
1480
+ const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
1481
+ // or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
1482
+ // getSnapshot must return the same reference while the data is unchanged
1483
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
1484
+ });
1132
1485
  } else {
1133
- out.push({ kind: "bailout", owner: r.component, target: r.component, prop: c.path, label: `bail out before setting ${c.path} in <${r.component}>`, detail: `${c.path} was set to a value deep-equal to the current one (new reference).` });
1486
+ out.push({
1487
+ kind: "bailout",
1488
+ owner: r.component,
1489
+ target: r.component,
1490
+ prop: c.path,
1491
+ label: `bail out before setting ${c.path} in <${r.component}>`,
1492
+ detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
1493
+ snippet: `// ${r.component}
1494
+ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1495
+ });
1134
1496
  }
1135
1497
  }
1136
1498
  return out;
@@ -1144,11 +1506,12 @@ function rankFixes(reports) {
1144
1506
  const key = fixKey(f);
1145
1507
  let agg = byKey.get(key);
1146
1508
  if (!agg) {
1147
- agg = { ...f, key, count: 0, components: {} };
1509
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
1148
1510
  byKey.set(key, agg);
1149
1511
  }
1150
1512
  agg.count++;
1151
1513
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
1514
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
1152
1515
  }
1153
1516
  }
1154
1517
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -1158,6 +1521,8 @@ function formatFixes(reports, limit = 10) {
1158
1521
  if (!ranked.length) return "No avoidable re-renders.";
1159
1522
  const lines = ranked.slice(0, limit).map((f, i) => `${String(i + 1).padStart(2)}. ${f.label} (removes ${f.count}: ${Object.entries(f.components).map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
1160
1523
  if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
1524
+ const roots = formatRootCauses(reports, 5);
1525
+ if (roots) lines.push("", "Root causes:", roots);
1161
1526
  return lines.join("\n");
1162
1527
  }
1163
1528
 
@@ -1256,7 +1621,7 @@ ${formatFixes(reports)}`);
1256
1621
  };
1257
1622
  }
1258
1623
 
1259
- // src/vitest.ts
1624
+ // src/runner-report.ts
1260
1625
  var OUT_ENV = "RERENDER_LENS_OUT";
1261
1626
  function setupRerenderLens(options = {}, hooks = {}) {
1262
1627
  ensureDevtoolsHook();
@@ -1291,49 +1656,55 @@ function slim(r) {
1291
1656
  state: void 0
1292
1657
  };
1293
1658
  }
1294
- var RerenderLensReporter = class {
1659
+ function createRunFile() {
1660
+ const dir = path.join(os.tmpdir(), "rerender-lens");
1661
+ fs.mkdirSync(dir, { recursive: true });
1662
+ const file = path.join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
1663
+ fs.writeFileSync(file, "");
1664
+ process.env[OUT_ENV] = file;
1665
+ return file;
1666
+ }
1667
+ function finishRun(file, options = {}) {
1668
+ const reports = readRun(file);
1669
+ const log = options.write ?? ((line) => void process.stdout.write(line + "\n"));
1670
+ const avoidable = reports.filter((r) => r.avoidable).length;
1671
+ let error = null;
1672
+ log("");
1673
+ log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
1674
+ if (avoidable) log(formatFixes(reports, options.limit ?? 10));
1675
+ if (options.exportTo) fs.writeFileSync(options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
1676
+ if (options.budget) {
1677
+ const budget = JSON.parse(fs.readFileSync(options.budget, "utf8"));
1678
+ const result = checkBudget(reports, budget);
1679
+ if (!result.ok) {
1680
+ const lines = result.violations.map((v) => ` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
1681
+ for (const line of lines) log(line);
1682
+ error = new Error(`rerender-lens: re-render budget exceeded (${options.budget})
1683
+ ${lines.join("\n")}`);
1684
+ } else log(` within budget (${options.budget})`);
1685
+ }
1686
+ try {
1687
+ fs.rmSync(file, { force: true });
1688
+ } catch {
1689
+ }
1690
+ if (process.env[OUT_ENV] === file) delete process.env[OUT_ENV];
1691
+ return { reports, error };
1692
+ }
1693
+ var RunReporterCore = class {
1295
1694
  constructor(options = {}) {
1296
1695
  this.done = false;
1297
1696
  this.options = options;
1298
- const dir = path.join(os.tmpdir(), "rerender-lens");
1299
- fs.mkdirSync(dir, { recursive: true });
1300
- this.file = path.join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
1301
- fs.writeFileSync(this.file, "");
1302
- process.env[OUT_ENV] = this.file;
1697
+ this.file = createRunFile();
1303
1698
  }
1304
- onInit() {
1699
+ /** Publish the path again (the runner may have replaced `process.env` since the constructor ran). */
1700
+ arm() {
1305
1701
  process.env[OUT_ENV] = this.file;
1306
1702
  }
1307
- onTestRunEnd() {
1308
- this.finish();
1309
- }
1310
- /** Vitest < 3 name for the same event. */
1311
- onFinished() {
1312
- this.finish();
1313
- }
1703
+ /** The run's result, or null when the run was already finished (runners fire two "done" events). */
1314
1704
  finish() {
1315
- if (this.done) return;
1705
+ if (this.done) return null;
1316
1706
  this.done = true;
1317
- const reports = readRun(this.file);
1318
- const log = this.options.write ?? ((line) => void process.stdout.write(line + "\n"));
1319
- const avoidable = reports.filter((r) => r.avoidable).length;
1320
- log("");
1321
- log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
1322
- if (avoidable) log(formatFixes(reports, this.options.limit ?? 10));
1323
- if (this.options.exportTo) fs.writeFileSync(this.options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
1324
- if (this.options.budget) {
1325
- const budget = JSON.parse(fs.readFileSync(this.options.budget, "utf8"));
1326
- const result = checkBudget(reports, budget);
1327
- if (!result.ok) {
1328
- for (const v of result.violations) log(` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
1329
- process.exitCode = 1;
1330
- } else log(` within budget (${this.options.budget})`);
1331
- }
1332
- try {
1333
- fs.rmSync(this.file, { force: true });
1334
- } catch {
1335
- }
1336
- if (process.env[OUT_ENV] === this.file) delete process.env[OUT_ENV];
1707
+ return finishRun(this.file, this.options);
1337
1708
  }
1338
1709
  };
1339
1710
  function readRun(file) {
@@ -1350,6 +1721,27 @@ function readRun(file) {
1350
1721
  return out;
1351
1722
  }
1352
1723
 
1724
+ // src/vitest.ts
1725
+ var RerenderLensReporter = class {
1726
+ constructor(options = {}) {
1727
+ this.core = new RunReporterCore(options);
1728
+ }
1729
+ onInit() {
1730
+ this.core.arm();
1731
+ }
1732
+ onTestRunEnd() {
1733
+ this.finish();
1734
+ }
1735
+ /** Vitest < 3 name for the same event. */
1736
+ onFinished() {
1737
+ this.finish();
1738
+ }
1739
+ finish() {
1740
+ const result = this.core.finish();
1741
+ if (result?.error) process.exitCode = 1;
1742
+ }
1743
+ };
1744
+
1353
1745
  exports.OUT_ENV = OUT_ENV;
1354
1746
  exports.checkBudget = checkBudget;
1355
1747
  exports.default = RerenderLensReporter;