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/cli.js CHANGED
@@ -4,50 +4,206 @@ import { createServer } from 'http';
4
4
  import { join, dirname } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
 
7
- // src/report.ts
8
- function storeAdvice(custom) {
9
- const chain = custom ?? [];
10
- if (chain.some((n) => /^useSelector$/.test(n)))
11
- 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";
12
- if (chain.some((n) => /^useAppSelector$/.test(n)))
13
- return "the selector returns a new object on every call: return a stored slice, pass shallowEqual, or memoize it with createSelector";
14
- if (chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore"))
15
- return "the store selector returns a new object on every call: select a primitive, or wrap the selector with useShallow (Zustand) / an equality function";
16
- 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";
7
+ // src/diff.ts
8
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
9
+
10
+ // src/causes.ts
11
+ 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);
12
+ var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
13
+ function indexByComponent(reports) {
14
+ const index = /* @__PURE__ */ new Map();
15
+ for (const r of reports) {
16
+ const list = index.get(r.component);
17
+ if (list) list.push(r);
18
+ else index.set(r.component, [r]);
19
+ }
20
+ return index;
21
+ }
22
+ function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
23
+ let cur = r;
24
+ const seen = /* @__PURE__ */ new Set([r]);
25
+ while (cur.trigger === "parent" && cur.parent) {
26
+ const parent = cur.parent;
27
+ const candidates = index.get(parent.name);
28
+ const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
29
+ if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
30
+ seen.add(p);
31
+ cur = p;
32
+ }
33
+ return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
34
+ }
35
+ function rootCausesOf(reports) {
36
+ const roots = /* @__PURE__ */ new Map();
37
+ const rootByReport = /* @__PURE__ */ new Map();
38
+ const index = indexByComponent(reports);
39
+ let avoidable = 0;
40
+ let wasted = 0;
41
+ for (const r of reports) {
42
+ if (!r.avoidable) continue;
43
+ avoidable++;
44
+ if (typeof r.selfDuration === "number") wasted += r.selfDuration;
45
+ const root = rootCauseOf(r, reports, index);
46
+ const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
47
+ const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
48
+ rootByReport.set(r, name);
49
+ let agg = roots.get(name);
50
+ if (!agg) {
51
+ agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
52
+ roots.set(name, agg);
53
+ }
54
+ agg.count++;
55
+ bump(agg.components, r.component);
56
+ }
57
+ return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
58
+ }
59
+ function groupByCommit(reports) {
60
+ const out = /* @__PURE__ */ new Map();
61
+ for (const r of reports) {
62
+ if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
63
+ const list = out.get(r.commitId);
64
+ if (list) list.push(r);
65
+ else out.set(r.commitId, [r]);
66
+ }
67
+ return out;
68
+ }
69
+ function rankRootCauses(reports) {
70
+ const byName = /* @__PURE__ */ new Map();
71
+ for (const commit of groupByCommit(reports).values()) {
72
+ for (const root of rootCausesOf(commit).roots) {
73
+ let agg = byName.get(root.name);
74
+ if (!agg) {
75
+ agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
76
+ byName.set(root.name, agg);
77
+ }
78
+ agg.commits++;
79
+ agg.count += root.count;
80
+ for (const [c, n] of root.components) bump(agg.components, c, n);
81
+ }
82
+ }
83
+ return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
84
+ }
85
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
86
+ function formatRootCauses(reports, limit = 5) {
87
+ const ranked = rankRootCauses(reports);
88
+ 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(", ")})`);
89
+ if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
90
+ return lines.join("\n");
17
91
  }
18
92
 
19
93
  // src/fixes.ts
20
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
94
+ var MAX_FIX_REPORTS = 50;
21
95
  var rootOf = (path) => path.split(/[.[]/)[0] || path;
96
+ var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
97
+ function shortValue(v, max = 60) {
98
+ let s;
99
+ try {
100
+ s = JSON.stringify(v);
101
+ } catch {
102
+ s = String(v);
103
+ }
104
+ if (s === void 0) s = String(v);
105
+ return s.length > max ? s.slice(0, max - 3) + "..." : s;
106
+ }
22
107
  function fixesFor(r) {
23
108
  const out = [];
24
- const owner = r.owner || r.parent && r.parent.name || "?";
25
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
109
+ const ownerName = r.owner || r.parent && r.parent.name || null;
110
+ const propChanges = r.propChanges || [];
111
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
26
112
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
113
+ const identical = changes.length === 0;
27
114
  out.push({
28
115
  kind: "memo",
29
116
  owner: r.component,
30
117
  target: r.component,
31
118
  prop: null,
32
119
  label: `Wrap <${r.component}> in React.memo`,
33
- 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.`
120
+ 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.`,
121
+ snippet: `// ${r.component}
122
+ import { memo } from 'react';
123
+
124
+ export const ${r.component} = memo(function ${r.component}(props) {
125
+ // ...
126
+ });
127
+ // class components: extend PureComponent instead`
34
128
  });
35
129
  }
36
- for (const c of r.propChanges) {
37
- if (!AVOIDABLE.has(c.kind)) continue;
130
+ for (const c of propChanges) {
131
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
132
+ const owner = ownerName || "?";
38
133
  const root = rootOf(c.path);
134
+ const id = identifier(root);
39
135
  if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
40
- 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.` });
136
+ out.push({
137
+ kind: "children",
138
+ owner,
139
+ target: r.component,
140
+ prop: "children",
141
+ label: `memoize children of <${r.component}> in <${owner}>`,
142
+ detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
143
+ snippet: `// ${owner}
144
+ import { useMemo } from 'react';
145
+
146
+ const children = useMemo(() => (
147
+ <>{/* the same elements */}</>
148
+ ), [/* deps */]);
149
+
150
+ <${r.component}>{children}</${r.component}>
151
+
152
+ // static children: hoist them to module scope
153
+ const STATIC = <em>hi</em>;`
154
+ });
41
155
  } else if (c.kind === "function") {
42
- 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}>.` });
156
+ out.push({
157
+ kind: "useCallback",
158
+ owner,
159
+ target: r.component,
160
+ prop: root,
161
+ label: `useCallback(${root}) in <${owner}>`,
162
+ detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
163
+ snippet: `// ${owner}
164
+ import { useCallback } from 'react';
165
+
166
+ const ${id} = useCallback((/* args */) => {
167
+ // ...
168
+ }, [/* deps */]);
169
+
170
+ <${r.component} ${root}={${id}} />`
171
+ });
43
172
  } else if (c.kind === "element") {
44
- 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}>.` });
173
+ out.push({
174
+ kind: "useMemoElement",
175
+ owner,
176
+ target: r.component,
177
+ prop: root,
178
+ label: `memoize element prop ${root} in <${owner}>`,
179
+ detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
180
+ snippet: `// ${owner}
181
+ import { useMemo } from 'react';
182
+
183
+ const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
184
+ // or pass it as children from a component that does not re-render`
185
+ });
45
186
  } else {
46
- 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}>.` });
187
+ const isArray = Array.isArray(c.next);
188
+ out.push({
189
+ kind: "useMemo",
190
+ owner,
191
+ target: r.component,
192
+ prop: root,
193
+ label: `useMemo(${root}) in <${owner}>`,
194
+ detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
195
+ snippet: `// ${owner}
196
+ import { useMemo } from 'react';
197
+
198
+ const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
199
+
200
+ // or, when it never changes, hoist it to module scope:
201
+ const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
202
+ });
47
203
  }
48
204
  }
49
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
50
- const isContext = c.hook === "useContext";
205
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
206
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
51
207
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
52
208
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
53
209
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -57,20 +213,63 @@ function fixesFor(r) {
57
213
  target: r.component,
58
214
  prop: ctxName,
59
215
  label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
60
- detail: `${c.changedKeys.length} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer re-rendered.`
216
+ 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.`,
217
+ snippet: `// ${providerOwner || "Provider"}
218
+ // one context per independently-changing slice
219
+ const ${identifier(ctxName)}Static = createContext(...);
220
+ const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
221
+
222
+ // or keep one context and let consumers select a slice:
223
+ const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
61
224
  });
62
225
  continue;
63
226
  }
64
- if (!AVOIDABLE.has(c.kind)) continue;
227
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
65
228
  if (isContext) {
66
- 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.` });
229
+ out.push({
230
+ kind: "contextValue",
231
+ owner: providerOwner || `${ctxName}.Provider`,
232
+ target: r.component,
233
+ prop: ctxName,
234
+ label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
235
+ detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
236
+ snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
237
+ const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
238
+ <${ctxName}.Provider value={value}>`
239
+ });
67
240
  } else if (c.hook === "useSyncExternalStore") {
68
241
  const chain = c.custom || [];
69
242
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
70
243
  const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
71
- 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) });
244
+ const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
245
+ out.push({
246
+ kind: "storeSnapshot",
247
+ owner: r.component,
248
+ target: r.component,
249
+ prop: c.path,
250
+ label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
251
+ 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.`,
252
+ snippet: redux ? `// ${r.component}
253
+ import { shallowEqual } from 'react-redux';
254
+ const slice = useSelector(selectSlice, shallowEqual);
255
+ // or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
256
+ import { useShallow } from 'zustand/react/shallow';
257
+ const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
258
+ // or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
259
+ // getSnapshot must return the same reference while the data is unchanged
260
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
261
+ });
72
262
  } else {
73
- 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).` });
263
+ out.push({
264
+ kind: "bailout",
265
+ owner: r.component,
266
+ target: r.component,
267
+ prop: c.path,
268
+ label: `bail out before setting ${c.path} in <${r.component}>`,
269
+ detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
270
+ snippet: `// ${r.component}
271
+ setState((prev) => (deepEqual(prev, next) ? prev : next));`
272
+ });
74
273
  }
75
274
  }
76
275
  return out;
@@ -84,11 +283,12 @@ function rankFixes(reports) {
84
283
  const key = fixKey(f);
85
284
  let agg = byKey.get(key);
86
285
  if (!agg) {
87
- agg = { ...f, key, count: 0, components: {} };
286
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
88
287
  byKey.set(key, agg);
89
288
  }
90
289
  agg.count++;
91
290
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
291
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
92
292
  }
93
293
  }
94
294
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -98,6 +298,8 @@ function formatFixes(reports, limit = 10) {
98
298
  if (!ranked.length) return "No avoidable re-renders.";
99
299
  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(", ")})`);
100
300
  if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
301
+ const roots = formatRootCauses(reports, 5);
302
+ if (roots) lines.push("", "Root causes:", roots);
101
303
  return lines.join("\n");
102
304
  }
103
305
 
@@ -287,12 +489,20 @@ function createRelayServer(options = {}) {
287
489
  clearInterval(ping);
288
490
  role.delete(res);
289
491
  });
492
+ const count = () => ({ __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } });
290
493
  if (role === apps) {
291
- send(panels, { __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } });
292
- req.on("close", () => send(panels, { __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } }));
494
+ send(panels, count());
495
+ req.on("close", () => send(panels, count()));
496
+ } else {
497
+ send(/* @__PURE__ */ new Set([res]), count());
293
498
  }
294
499
  return;
295
500
  }
501
+ if (u.pathname === "/favicon.ico") {
502
+ res.statusCode = 204;
503
+ res.end();
504
+ return;
505
+ }
296
506
  if (u.pathname === "/message" && req.method === "POST") {
297
507
  try {
298
508
  const parsed = JSON.parse(await readBody(req) || "null");
@@ -393,6 +603,13 @@ function main(argv) {
393
603
  console.log(formatFixes(reports, Number(flag("--limit") || 20)));
394
604
  return 0;
395
605
  }
606
+ case "causes": {
607
+ const file = positional[0];
608
+ if (!file) throw new Error("usage: rerender-lens causes <export.json> [--limit N]");
609
+ const { reports } = parseExport(read(file));
610
+ console.log(formatRootCauses(reports, Number(flag("--limit") || 10)) || "No root causes: no avoidable re-renders, or the reports carry no commit ids.");
611
+ return 0;
612
+ }
396
613
  case "summary": {
397
614
  const file = positional[0];
398
615
  if (!file) throw new Error("usage: rerender-lens summary <export.json> [--out summary.json]");
@@ -436,7 +653,7 @@ ${cmp.regressions.length} component${cmp.regressions.length === 1 ? "" : "s"} re
436
653
  return 1;
437
654
  }
438
655
  default:
439
- console.error("usage: rerender-lens <panel|fixes|summary|compare|budget> ...");
656
+ console.error("usage: rerender-lens <panel|fixes|causes|summary|compare|budget> ...");
440
657
  return cmd ? 1 : 0;
441
658
  }
442
659
  } catch (e) {