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