rerender-lens 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +192 -362
  3. package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
  4. package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
  5. package/dist/cli.cjs +248 -31
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +248 -31
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-CZebzpF6.d.ts → devtools-BYWat7nQ.d.ts} +7 -1
  10. package/dist/{devtools-BkCct3cJ.d.cts → devtools-DJi_6AfJ.d.cts} +7 -1
  11. package/dist/index.cjs +439 -41
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +131 -8
  14. package/dist/index.d.ts +131 -8
  15. package/dist/index.js +424 -42
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1673 -0
  18. package/dist/jest-setup.cjs.map +1 -0
  19. package/dist/jest-setup.d.cts +8 -0
  20. package/dist/jest-setup.d.ts +8 -0
  21. package/dist/jest-setup.js +1671 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1760 -0
  24. package/dist/jest.cjs.map +1 -0
  25. package/dist/jest.d.cts +23 -0
  26. package/dist/jest.d.ts +23 -0
  27. package/dist/jest.js +1751 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.d.ts} +8 -4
  31. package/dist/playwright.cjs +230 -28
  32. package/dist/playwright.cjs.map +1 -1
  33. package/dist/playwright.d.cts +4 -4
  34. package/dist/playwright.d.ts +4 -4
  35. package/dist/playwright.js +230 -28
  36. package/dist/playwright.js.map +1 -1
  37. package/dist/relay.cjs +10 -2
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.js +10 -2
  40. package/dist/relay.js.map +1 -1
  41. package/dist/rerender-lens.iife.js +356 -54
  42. package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
  43. package/dist/runner-report-CW8X5RwK.d.ts +46 -0
  44. package/dist/setup.cjs +112 -27
  45. package/dist/setup.cjs.map +1 -1
  46. package/dist/setup.js +112 -27
  47. package/dist/setup.js.map +1 -1
  48. package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
  49. package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
  50. package/dist/vite.d.cts +1 -1
  51. package/dist/vite.d.ts +1 -1
  52. package/dist/vitest-setup.cjs +337 -41
  53. package/dist/vitest-setup.cjs.map +1 -1
  54. package/dist/vitest-setup.d.cts +4 -4
  55. package/dist/vitest-setup.d.ts +4 -4
  56. package/dist/vitest-setup.js +337 -41
  57. package/dist/vitest-setup.js.map +1 -1
  58. package/dist/vitest.cjs +400 -77
  59. package/dist/vitest.cjs.map +1 -1
  60. package/dist/vitest.d.cts +21 -39
  61. package/dist/vitest.d.ts +21 -39
  62. package/dist/vitest.js +401 -78
  63. package/dist/vitest.js.map +1 -1
  64. package/package.json +40 -4
  65. package/panel/panel.css +1 -0
  66. package/panel/panel.js +499 -309
@@ -1,9 +1,9 @@
1
- import { R as RenderReport } from './types-BzUEVkxJ.cjs';
1
+ import { a as ReportLike, R as RenderReport } from './types-DW5-N2XH.js';
2
2
 
3
3
  /**
4
4
  * Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
5
- * The same logic drives the extension's Fixes view; here it is typed on `RenderReport` for tests,
6
- * CI budgets and the CLI. Pure.
5
+ * One implementation for the library (tests, CI budgets, the CLI, typed on `RenderReport`) and the
6
+ * DevTools panel (serialized reports): everything here reads only `ReportLike`. Pure.
7
7
  */
8
8
 
9
9
  type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
@@ -16,19 +16,29 @@ interface Fix {
16
16
  prop: string | null;
17
17
  label: string;
18
18
  detail: string;
19
+ /** Code sketch of the fix, `// <owner>` on the first line. */
20
+ snippet: string;
19
21
  }
20
- interface RankedFix extends Fix {
22
+ interface RankedFix<R extends ReportLike = RenderReport> extends Fix {
21
23
  key: string;
22
24
  /** Avoidable re-renders this fix removes. */
23
25
  count: number;
24
26
  components: Record<string, number>;
27
+ /** The first (at most 50) reports the fix applies to. */
28
+ reports: R[];
25
29
  }
26
- declare function fixesFor(r: RenderReport): Fix[];
30
+ /** `JSON.stringify` cut to `max` characters, for snippets and markdown; never throws. */
31
+ declare function shortValue(v: unknown, max?: number): string;
32
+ /** The concrete fixes one report suggests, each attributed to the component (file) that must change. */
33
+ declare function fixesFor(r: ReportLike): Fix[];
27
34
  declare const fixKey: (f: Fix) => string;
28
- /** Every fix across `reports`, most re-renders removed first. */
29
- declare function rankFixes(reports: RenderReport[]): RankedFix[];
30
- /** Multi-line text for a test failure or a CI log. */
31
- declare function formatFixes(reports: RenderReport[], limit?: number): string;
35
+ /** Every fix across `reports`, most avoidable re-renders removed first. */
36
+ declare function rankFixes<R extends ReportLike>(reports: readonly R[]): RankedFix<R>[];
37
+ /**
38
+ * Multi-line text for a test failure or a CI log: the ranked fixes, then (when the reports carry
39
+ * `commitId`) a "Root causes" section naming the components whose own changes started the cascades.
40
+ */
41
+ declare function formatFixes(reports: readonly ReportLike[], limit?: number): string;
32
42
 
33
43
  /**
34
44
  * Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
@@ -61,4 +71,4 @@ declare function toBudget(reports: RenderReport[]): Budget;
61
71
  /** Throws with the violations and the ranked fixes; use it from a test. */
62
72
  declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
63
73
 
64
- export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };
74
+ export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, shortValue as s, toBudget as t };
@@ -1,9 +1,9 @@
1
- import { R as RenderReport } from './types-BzUEVkxJ.js';
1
+ import { a as ReportLike, R as RenderReport } from './types-DW5-N2XH.cjs';
2
2
 
3
3
  /**
4
4
  * Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
5
- * The same logic drives the extension's Fixes view; here it is typed on `RenderReport` for tests,
6
- * CI budgets and the CLI. Pure.
5
+ * One implementation for the library (tests, CI budgets, the CLI, typed on `RenderReport`) and the
6
+ * DevTools panel (serialized reports): everything here reads only `ReportLike`. Pure.
7
7
  */
8
8
 
9
9
  type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
@@ -16,19 +16,29 @@ interface Fix {
16
16
  prop: string | null;
17
17
  label: string;
18
18
  detail: string;
19
+ /** Code sketch of the fix, `// <owner>` on the first line. */
20
+ snippet: string;
19
21
  }
20
- interface RankedFix extends Fix {
22
+ interface RankedFix<R extends ReportLike = RenderReport> extends Fix {
21
23
  key: string;
22
24
  /** Avoidable re-renders this fix removes. */
23
25
  count: number;
24
26
  components: Record<string, number>;
27
+ /** The first (at most 50) reports the fix applies to. */
28
+ reports: R[];
25
29
  }
26
- declare function fixesFor(r: RenderReport): Fix[];
30
+ /** `JSON.stringify` cut to `max` characters, for snippets and markdown; never throws. */
31
+ declare function shortValue(v: unknown, max?: number): string;
32
+ /** The concrete fixes one report suggests, each attributed to the component (file) that must change. */
33
+ declare function fixesFor(r: ReportLike): Fix[];
27
34
  declare const fixKey: (f: Fix) => string;
28
- /** Every fix across `reports`, most re-renders removed first. */
29
- declare function rankFixes(reports: RenderReport[]): RankedFix[];
30
- /** Multi-line text for a test failure or a CI log. */
31
- declare function formatFixes(reports: RenderReport[], limit?: number): string;
35
+ /** Every fix across `reports`, most avoidable re-renders removed first. */
36
+ declare function rankFixes<R extends ReportLike>(reports: readonly R[]): RankedFix<R>[];
37
+ /**
38
+ * Multi-line text for a test failure or a CI log: the ranked fixes, then (when the reports carry
39
+ * `commitId`) a "Root causes" section naming the components whose own changes started the cascades.
40
+ */
41
+ declare function formatFixes(reports: readonly ReportLike[], limit?: number): string;
32
42
 
33
43
  /**
34
44
  * Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
@@ -61,4 +71,4 @@ declare function toBudget(reports: RenderReport[]): Budget;
61
71
  /** Throws with the violations and the ranked fixes; use it from a test. */
62
72
  declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
63
73
 
64
- export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };
74
+ export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, shortValue as s, toBudget as t };
package/dist/cli.cjs CHANGED
@@ -10,50 +10,206 @@ var url = require('url');
10
10
  var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
11
11
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
12
12
 
13
- // src/report.ts
14
- function storeAdvice(custom) {
15
- const chain = custom ?? [];
16
- if (chain.some((n) => /^useSelector$/.test(n)))
17
- return "the Redux selector returns a new object on every call: return a stored slice, pass shallowEqual as the equality function, or memoize it with createSelector";
18
- if (chain.some((n) => /^useAppSelector$/.test(n)))
19
- return "the selector returns a new object on every call: return a stored slice, pass shallowEqual, or memoize it with createSelector";
20
- if (chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore"))
21
- return "the store selector returns a new object on every call: select a primitive, or wrap the selector with useShallow (Zustand) / an equality function";
22
- return "getSnapshot returns a new reference with the same contents: cache the snapshot in the store and return the same object while the data is unchanged";
13
+ // src/diff.ts
14
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
15
+
16
+ // src/causes.ts
17
+ 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);
18
+ var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
19
+ function indexByComponent(reports) {
20
+ const index = /* @__PURE__ */ new Map();
21
+ for (const r of reports) {
22
+ const list = index.get(r.component);
23
+ if (list) list.push(r);
24
+ else index.set(r.component, [r]);
25
+ }
26
+ return index;
27
+ }
28
+ function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
29
+ let cur = r;
30
+ const seen = /* @__PURE__ */ new Set([r]);
31
+ while (cur.trigger === "parent" && cur.parent) {
32
+ const parent = cur.parent;
33
+ const candidates = index.get(parent.name);
34
+ const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
35
+ if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
36
+ seen.add(p);
37
+ cur = p;
38
+ }
39
+ return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
40
+ }
41
+ function rootCausesOf(reports) {
42
+ const roots = /* @__PURE__ */ new Map();
43
+ const rootByReport = /* @__PURE__ */ new Map();
44
+ const index = indexByComponent(reports);
45
+ let avoidable = 0;
46
+ let wasted = 0;
47
+ for (const r of reports) {
48
+ if (!r.avoidable) continue;
49
+ avoidable++;
50
+ if (typeof r.selfDuration === "number") wasted += r.selfDuration;
51
+ const root = rootCauseOf(r, reports, index);
52
+ const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
53
+ const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
54
+ rootByReport.set(r, name);
55
+ let agg = roots.get(name);
56
+ if (!agg) {
57
+ agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
58
+ roots.set(name, agg);
59
+ }
60
+ agg.count++;
61
+ bump(agg.components, r.component);
62
+ }
63
+ return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
64
+ }
65
+ function groupByCommit(reports) {
66
+ const out = /* @__PURE__ */ new Map();
67
+ for (const r of reports) {
68
+ if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
69
+ const list = out.get(r.commitId);
70
+ if (list) list.push(r);
71
+ else out.set(r.commitId, [r]);
72
+ }
73
+ return out;
74
+ }
75
+ function rankRootCauses(reports) {
76
+ const byName = /* @__PURE__ */ new Map();
77
+ for (const commit of groupByCommit(reports).values()) {
78
+ for (const root of rootCausesOf(commit).roots) {
79
+ let agg = byName.get(root.name);
80
+ if (!agg) {
81
+ agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
82
+ byName.set(root.name, agg);
83
+ }
84
+ agg.commits++;
85
+ agg.count += root.count;
86
+ for (const [c, n] of root.components) bump(agg.components, c, n);
87
+ }
88
+ }
89
+ return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
90
+ }
91
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
92
+ function formatRootCauses(reports, limit = 5) {
93
+ const ranked = rankRootCauses(reports);
94
+ 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(", ")})`);
95
+ if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
96
+ return lines.join("\n");
23
97
  }
24
98
 
25
99
  // src/fixes.ts
26
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
100
+ var MAX_FIX_REPORTS = 50;
27
101
  var rootOf = (path) => path.split(/[.[]/)[0] || path;
102
+ var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
103
+ function shortValue(v, max = 60) {
104
+ let s;
105
+ try {
106
+ s = JSON.stringify(v);
107
+ } catch {
108
+ s = String(v);
109
+ }
110
+ if (s === void 0) s = String(v);
111
+ return s.length > max ? s.slice(0, max - 3) + "..." : s;
112
+ }
28
113
  function fixesFor(r) {
29
114
  const out = [];
30
- const owner = r.owner || r.parent && r.parent.name || "?";
31
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
115
+ const ownerName = r.owner || r.parent && r.parent.name || null;
116
+ const propChanges = r.propChanges || [];
117
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
32
118
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
119
+ const identical = changes.length === 0;
33
120
  out.push({
34
121
  kind: "memo",
35
122
  owner: r.component,
36
123
  target: r.component,
37
124
  prop: null,
38
125
  label: `Wrap <${r.component}> in React.memo`,
39
- 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.`
126
+ 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.`,
127
+ snippet: `// ${r.component}
128
+ import { memo } from 'react';
129
+
130
+ export const ${r.component} = memo(function ${r.component}(props) {
131
+ // ...
132
+ });
133
+ // class components: extend PureComponent instead`
40
134
  });
41
135
  }
42
- for (const c of r.propChanges) {
43
- if (!AVOIDABLE.has(c.kind)) continue;
136
+ for (const c of propChanges) {
137
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
138
+ const owner = ownerName || "?";
44
139
  const root = rootOf(c.path);
140
+ const id = identifier(root);
45
141
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
46
- 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.` });
142
+ out.push({
143
+ kind: "children",
144
+ owner,
145
+ target: r.component,
146
+ prop: "children",
147
+ label: `memoize children of <${r.component}> in <${owner}>`,
148
+ detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
149
+ snippet: `// ${owner}
150
+ import { useMemo } from 'react';
151
+
152
+ const children = useMemo(() => (
153
+ <>{/* the same elements */}</>
154
+ ), [/* deps */]);
155
+
156
+ <${r.component}>{children}</${r.component}>
157
+
158
+ // static children: hoist them to module scope
159
+ const STATIC = <em>hi</em>;`
160
+ });
47
161
  } else if (c.kind === "function") {
48
- 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}>.` });
162
+ out.push({
163
+ kind: "useCallback",
164
+ owner,
165
+ target: r.component,
166
+ prop: root,
167
+ label: `useCallback(${root}) in <${owner}>`,
168
+ detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
169
+ snippet: `// ${owner}
170
+ import { useCallback } from 'react';
171
+
172
+ const ${id} = useCallback((/* args */) => {
173
+ // ...
174
+ }, [/* deps */]);
175
+
176
+ <${r.component} ${root}={${id}} />`
177
+ });
49
178
  } else if (c.kind === "element") {
50
- 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}>.` });
179
+ out.push({
180
+ kind: "useMemoElement",
181
+ owner,
182
+ target: r.component,
183
+ prop: root,
184
+ label: `memoize element prop ${root} in <${owner}>`,
185
+ detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
186
+ snippet: `// ${owner}
187
+ import { useMemo } from 'react';
188
+
189
+ const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
190
+ // or pass it as children from a component that does not re-render`
191
+ });
51
192
  } else {
52
- 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}>.` });
193
+ const isArray = Array.isArray(c.next);
194
+ out.push({
195
+ kind: "useMemo",
196
+ owner,
197
+ target: r.component,
198
+ prop: root,
199
+ label: `useMemo(${root}) in <${owner}>`,
200
+ detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
201
+ snippet: `// ${owner}
202
+ import { useMemo } from 'react';
203
+
204
+ const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
205
+
206
+ // or, when it never changes, hoist it to module scope:
207
+ const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
208
+ });
53
209
  }
54
210
  }
55
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
56
- const isContext = c.hook === "useContext";
211
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
212
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
57
213
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
58
214
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
59
215
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -63,20 +219,63 @@ function fixesFor(r) {
63
219
  target: r.component,
64
220
  prop: ctxName,
65
221
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
66
- detail: `${c.changedKeys.length} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer re-rendered.`
222
+ 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.`,
223
+ snippet: `// ${providerOwner || "Provider"}
224
+ // one context per independently-changing slice
225
+ const ${identifier(ctxName)}Static = createContext(...);
226
+ const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
227
+
228
+ // or keep one context and let consumers select a slice:
229
+ const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
67
230
  });
68
231
  continue;
69
232
  }
70
- if (!AVOIDABLE.has(c.kind)) continue;
233
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
71
234
  if (isContext) {
72
- 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.` });
235
+ out.push({
236
+ kind: "contextValue",
237
+ owner: providerOwner || `${ctxName}.Provider`,
238
+ target: r.component,
239
+ prop: ctxName,
240
+ label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
241
+ detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
242
+ snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
243
+ const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
244
+ <${ctxName}.Provider value={value}>`
245
+ });
73
246
  } else if (c.hook === "useSyncExternalStore") {
74
247
  const chain = c.custom || [];
75
248
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
76
249
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
77
- 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) });
250
+ const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
251
+ out.push({
252
+ kind: "storeSnapshot",
253
+ owner: r.component,
254
+ target: r.component,
255
+ prop: c.path,
256
+ label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
257
+ 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.`,
258
+ snippet: redux ? `// ${r.component}
259
+ import { shallowEqual } from 'react-redux';
260
+ const slice = useSelector(selectSlice, shallowEqual);
261
+ // or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
262
+ import { useShallow } from 'zustand/react/shallow';
263
+ const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
264
+ // or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
265
+ // getSnapshot must return the same reference while the data is unchanged
266
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
267
+ });
78
268
  } else {
79
- 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).` });
269
+ out.push({
270
+ kind: "bailout",
271
+ owner: r.component,
272
+ target: r.component,
273
+ prop: c.path,
274
+ label: `bail out before setting ${c.path} in <${r.component}>`,
275
+ detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
276
+ snippet: `// ${r.component}
277
+ setState((prev) => (deepEqual(prev, next) ? prev : next));`
278
+ });
80
279
  }
81
280
  }
82
281
  return out;
@@ -90,11 +289,12 @@ function rankFixes(reports) {
90
289
  const key = fixKey(f);
91
290
  let agg = byKey.get(key);
92
291
  if (!agg) {
93
- agg = { ...f, key, count: 0, components: {} };
292
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
94
293
  byKey.set(key, agg);
95
294
  }
96
295
  agg.count++;
97
296
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
297
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
98
298
  }
99
299
  }
100
300
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -104,6 +304,8 @@ function formatFixes(reports, limit = 10) {
104
304
  if (!ranked.length) return "No avoidable re-renders.";
105
305
  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(", ")})`);
106
306
  if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
307
+ const roots = formatRootCauses(reports, 5);
308
+ if (roots) lines.push("", "Root causes:", roots);
107
309
  return lines.join("\n");
108
310
  }
109
311
 
@@ -293,12 +495,20 @@ function createRelayServer(options = {}) {
293
495
  clearInterval(ping);
294
496
  role.delete(res);
295
497
  });
498
+ const count = () => ({ __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } });
296
499
  if (role === apps) {
297
- send(panels, { __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } });
298
- req.on("close", () => send(panels, { __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } }));
500
+ send(panels, count());
501
+ req.on("close", () => send(panels, count()));
502
+ } else {
503
+ send(/* @__PURE__ */ new Set([res]), count());
299
504
  }
300
505
  return;
301
506
  }
507
+ if (u.pathname === "/favicon.ico") {
508
+ res.statusCode = 204;
509
+ res.end();
510
+ return;
511
+ }
302
512
  if (u.pathname === "/message" && req.method === "POST") {
303
513
  try {
304
514
  const parsed = JSON.parse(await readBody(req) || "null");
@@ -399,6 +609,13 @@ function main(argv) {
399
609
  console.log(formatFixes(reports, Number(flag("--limit") || 20)));
400
610
  return 0;
401
611
  }
612
+ case "causes": {
613
+ const file = positional[0];
614
+ if (!file) throw new Error("usage: rerender-lens causes <export.json> [--limit N]");
615
+ const { reports } = parseExport(read(file));
616
+ console.log(formatRootCauses(reports, Number(flag("--limit") || 10)) || "No root causes: no avoidable re-renders, or the reports carry no commit ids.");
617
+ return 0;
618
+ }
402
619
  case "summary": {
403
620
  const file = positional[0];
404
621
  if (!file) throw new Error("usage: rerender-lens summary <export.json> [--out summary.json]");
@@ -442,7 +659,7 @@ ${cmp.regressions.length} component${cmp.regressions.length === 1 ? "" : "s"} re
442
659
  return 1;
443
660
  }
444
661
  default:
445
- console.error("usage: rerender-lens <panel|fixes|summary|compare|budget> ...");
662
+ console.error("usage: rerender-lens <panel|fixes|causes|summary|compare|budget> ...");
446
663
  return cmd ? 1 : 0;
447
664
  }
448
665
  } catch (e) {