rerender-lens 0.4.0 → 0.7.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 (68) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +208 -359
  3. package/dist/{budget-LkNjGtRc.d.ts → budget-CA1MS7lB.d.cts} +20 -10
  4. package/dist/{budget-COBu7jBU.d.cts → budget-ok1VDJ4t.d.ts} +20 -10
  5. package/dist/cli.cjs +322 -53
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +322 -53
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-BkCct3cJ.d.cts → devtools-Cl5n_52v.d.ts} +17 -2
  10. package/dist/{devtools-CZebzpF6.d.ts → devtools-UwQx2VYS.d.cts} +17 -2
  11. package/dist/index.cjs +537 -49
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +137 -8
  14. package/dist/index.d.ts +137 -8
  15. package/dist/index.js +521 -50
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +1690 -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 +1688 -0
  22. package/dist/jest-setup.js.map +1 -0
  23. package/dist/jest.cjs +1777 -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 +1768 -0
  28. package/dist/jest.js.map +1 -0
  29. package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-B5poAEvE.d.cts} +8 -4
  30. package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-CqQW0Eum.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 +71 -19
  38. package/dist/relay.cjs.map +1 -1
  39. package/dist/relay.d.cts +25 -3
  40. package/dist/relay.d.ts +25 -3
  41. package/dist/relay.js +71 -19
  42. package/dist/relay.js.map +1 -1
  43. package/dist/rerender-lens.iife.js +457 -62
  44. package/dist/runner-report-Bvqoe89M.d.ts +46 -0
  45. package/dist/runner-report-CvpDg2zt.d.cts +46 -0
  46. package/dist/setup.cjs +209 -35
  47. package/dist/setup.cjs.map +1 -1
  48. package/dist/setup.js +209 -35
  49. package/dist/setup.js.map +1 -1
  50. package/dist/{types-BzUEVkxJ.d.cts → types-DwNN5cWe.d.cts} +82 -2
  51. package/dist/{types-BzUEVkxJ.d.ts → types-DwNN5cWe.d.ts} +82 -2
  52. package/dist/vite.d.cts +1 -1
  53. package/dist/vite.d.ts +1 -1
  54. package/dist/vitest-setup.cjs +357 -44
  55. package/dist/vitest-setup.cjs.map +1 -1
  56. package/dist/vitest-setup.d.cts +4 -4
  57. package/dist/vitest-setup.d.ts +4 -4
  58. package/dist/vitest-setup.js +357 -44
  59. package/dist/vitest-setup.js.map +1 -1
  60. package/dist/vitest.cjs +420 -80
  61. package/dist/vitest.cjs.map +1 -1
  62. package/dist/vitest.d.cts +21 -39
  63. package/dist/vitest.d.ts +21 -39
  64. package/dist/vitest.js +421 -81
  65. package/dist/vitest.js.map +1 -1
  66. package/package.json +41 -4
  67. package/panel/panel.css +49 -0
  68. package/panel/panel.js +871 -353
package/dist/cli.cjs CHANGED
@@ -5,55 +5,212 @@ var fs = require('fs');
5
5
  var http = require('http');
6
6
  var path = require('path');
7
7
  var url = require('url');
8
+ var crypto = require('crypto');
8
9
 
9
10
  // node_modules/tsup/assets/cjs_shims.js
10
11
  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
12
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
12
13
 
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";
14
+ // src/diff.ts
15
+ var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
16
+
17
+ // src/causes.ts
18
+ 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);
19
+ var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
20
+ function indexByComponent(reports) {
21
+ const index = /* @__PURE__ */ new Map();
22
+ for (const r of reports) {
23
+ const list = index.get(r.component);
24
+ if (list) list.push(r);
25
+ else index.set(r.component, [r]);
26
+ }
27
+ return index;
28
+ }
29
+ function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
30
+ let cur = r;
31
+ const seen = /* @__PURE__ */ new Set([r]);
32
+ while (cur.trigger === "parent" && cur.parent) {
33
+ const parent = cur.parent;
34
+ const candidates = index.get(parent.name);
35
+ const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
36
+ if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
37
+ seen.add(p);
38
+ cur = p;
39
+ }
40
+ return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
41
+ }
42
+ function rootCausesOf(reports) {
43
+ const roots = /* @__PURE__ */ new Map();
44
+ const rootByReport = /* @__PURE__ */ new Map();
45
+ const index = indexByComponent(reports);
46
+ let avoidable = 0;
47
+ let wasted = 0;
48
+ for (const r of reports) {
49
+ if (!r.avoidable) continue;
50
+ avoidable++;
51
+ if (typeof r.selfDuration === "number") wasted += r.selfDuration;
52
+ const root = rootCauseOf(r, reports, index);
53
+ const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
54
+ const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
55
+ rootByReport.set(r, name);
56
+ let agg = roots.get(name);
57
+ if (!agg) {
58
+ agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
59
+ roots.set(name, agg);
60
+ }
61
+ agg.count++;
62
+ bump(agg.components, r.component);
63
+ }
64
+ return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
65
+ }
66
+ function groupByCommit(reports) {
67
+ const out = /* @__PURE__ */ new Map();
68
+ for (const r of reports) {
69
+ if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
70
+ const list = out.get(r.commitId);
71
+ if (list) list.push(r);
72
+ else out.set(r.commitId, [r]);
73
+ }
74
+ return out;
75
+ }
76
+ function rankRootCauses(reports) {
77
+ const byName = /* @__PURE__ */ new Map();
78
+ for (const commit of groupByCommit(reports).values()) {
79
+ for (const root of rootCausesOf(commit).roots) {
80
+ let agg = byName.get(root.name);
81
+ if (!agg) {
82
+ agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
83
+ byName.set(root.name, agg);
84
+ }
85
+ agg.commits++;
86
+ agg.count += root.count;
87
+ for (const [c, n] of root.components) bump(agg.components, c, n);
88
+ }
89
+ }
90
+ return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
91
+ }
92
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
93
+ function formatRootCauses(reports, limit = 5) {
94
+ const ranked = rankRootCauses(reports);
95
+ 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(", ")})`);
96
+ if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
97
+ return lines.join("\n");
23
98
  }
24
99
 
25
100
  // src/fixes.ts
26
- var AVOIDABLE = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
101
+ var MAX_FIX_REPORTS = 50;
27
102
  var rootOf = (path) => path.split(/[.[]/)[0] || path;
103
+ var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
104
+ function shortValue(v, max = 60) {
105
+ let s;
106
+ try {
107
+ s = JSON.stringify(v);
108
+ } catch {
109
+ s = String(v);
110
+ }
111
+ if (s === void 0) s = String(v);
112
+ return s.length > max ? s.slice(0, max - 3) + "..." : s;
113
+ }
28
114
  function fixesFor(r) {
29
115
  const out = [];
30
- const owner = r.owner || r.parent && r.parent.name || "?";
31
- const changes = [...r.propChanges, ...r.stateChanges, ...r.hookChanges];
116
+ const ownerName = r.owner || r.parent && r.parent.name || null;
117
+ const propChanges = r.propChanges || [];
118
+ const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
32
119
  if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
120
+ const identical = changes.length === 0;
33
121
  out.push({
34
122
  kind: "memo",
35
123
  owner: r.component,
36
124
  target: r.component,
37
125
  prop: null,
38
126
  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.`
127
+ 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.`,
128
+ snippet: `// ${r.component}
129
+ import { memo } from 'react';
130
+
131
+ export const ${r.component} = memo(function ${r.component}(props) {
132
+ // ...
133
+ });
134
+ // class components: extend PureComponent instead`
40
135
  });
41
136
  }
42
- for (const c of r.propChanges) {
43
- if (!AVOIDABLE.has(c.kind)) continue;
137
+ for (const c of propChanges) {
138
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
139
+ const owner = ownerName || "?";
44
140
  const root = rootOf(c.path);
141
+ const id = identifier(root);
45
142
  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.` });
143
+ out.push({
144
+ kind: "children",
145
+ owner,
146
+ target: r.component,
147
+ prop: "children",
148
+ label: `memoize children of <${r.component}> in <${owner}>`,
149
+ detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
150
+ snippet: `// ${owner}
151
+ import { useMemo } from 'react';
152
+
153
+ const children = useMemo(() => (
154
+ <>{/* the same elements */}</>
155
+ ), [/* deps */]);
156
+
157
+ <${r.component}>{children}</${r.component}>
158
+
159
+ // static children: hoist them to module scope
160
+ const STATIC = <em>hi</em>;`
161
+ });
47
162
  } 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}>.` });
163
+ out.push({
164
+ kind: "useCallback",
165
+ owner,
166
+ target: r.component,
167
+ prop: root,
168
+ label: `useCallback(${root}) in <${owner}>`,
169
+ detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
170
+ snippet: `// ${owner}
171
+ import { useCallback } from 'react';
172
+
173
+ const ${id} = useCallback((/* args */) => {
174
+ // ...
175
+ }, [/* deps */]);
176
+
177
+ <${r.component} ${root}={${id}} />`
178
+ });
49
179
  } 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}>.` });
180
+ out.push({
181
+ kind: "useMemoElement",
182
+ owner,
183
+ target: r.component,
184
+ prop: root,
185
+ label: `memoize element prop ${root} in <${owner}>`,
186
+ detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
187
+ snippet: `// ${owner}
188
+ import { useMemo } from 'react';
189
+
190
+ const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
191
+ // or pass it as children from a component that does not re-render`
192
+ });
51
193
  } 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}>.` });
194
+ const isArray = Array.isArray(c.next);
195
+ out.push({
196
+ kind: "useMemo",
197
+ owner,
198
+ target: r.component,
199
+ prop: root,
200
+ label: `useMemo(${root}) in <${owner}>`,
201
+ detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
202
+ snippet: `// ${owner}
203
+ import { useMemo } from 'react';
204
+
205
+ const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
206
+
207
+ // or, when it never changes, hoist it to module scope:
208
+ const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
209
+ });
53
210
  }
54
211
  }
55
- for (const c of [...r.stateChanges, ...r.hookChanges]) {
56
- const isContext = c.hook === "useContext";
212
+ for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
213
+ const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
57
214
  const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
58
215
  const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
59
216
  if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
@@ -63,20 +220,63 @@ function fixesFor(r) {
63
220
  target: r.component,
64
221
  prop: ctxName,
65
222
  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.`
223
+ 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.`,
224
+ snippet: `// ${providerOwner || "Provider"}
225
+ // one context per independently-changing slice
226
+ const ${identifier(ctxName)}Static = createContext(...);
227
+ const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
228
+
229
+ // or keep one context and let consumers select a slice:
230
+ const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
67
231
  });
68
232
  continue;
69
233
  }
70
- if (!AVOIDABLE.has(c.kind)) continue;
234
+ if (!AVOIDABLE_KINDS.has(c.kind)) continue;
71
235
  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.` });
236
+ out.push({
237
+ kind: "contextValue",
238
+ owner: providerOwner || `${ctxName}.Provider`,
239
+ target: r.component,
240
+ prop: ctxName,
241
+ label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
242
+ detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
243
+ snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
244
+ const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
245
+ <${ctxName}.Provider value={value}>`
246
+ });
73
247
  } else if (c.hook === "useSyncExternalStore") {
74
248
  const chain = c.custom || [];
75
249
  const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
76
250
  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) });
251
+ const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
252
+ out.push({
253
+ kind: "storeSnapshot",
254
+ owner: r.component,
255
+ target: r.component,
256
+ prop: c.path,
257
+ label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
258
+ 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.`,
259
+ snippet: redux ? `// ${r.component}
260
+ import { shallowEqual } from 'react-redux';
261
+ const slice = useSelector(selectSlice, shallowEqual);
262
+ // or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
263
+ import { useShallow } from 'zustand/react/shallow';
264
+ const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
265
+ // or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
266
+ // getSnapshot must return the same reference while the data is unchanged
267
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
268
+ });
78
269
  } 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).` });
270
+ out.push({
271
+ kind: "bailout",
272
+ owner: r.component,
273
+ target: r.component,
274
+ prop: c.path,
275
+ label: `bail out before setting ${c.path} in <${r.component}>`,
276
+ detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
277
+ snippet: `// ${r.component}
278
+ setState((prev) => (deepEqual(prev, next) ? prev : next));`
279
+ });
80
280
  }
81
281
  }
82
282
  return out;
@@ -90,11 +290,12 @@ function rankFixes(reports) {
90
290
  const key = fixKey(f);
91
291
  let agg = byKey.get(key);
92
292
  if (!agg) {
93
- agg = { ...f, key, count: 0, components: {} };
293
+ agg = { ...f, key, count: 0, components: {}, reports: [] };
94
294
  byKey.set(key, agg);
95
295
  }
96
296
  agg.count++;
97
297
  agg.components[r.component] = (agg.components[r.component] || 0) + 1;
298
+ if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
98
299
  }
99
300
  }
100
301
  return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -104,6 +305,8 @@ function formatFixes(reports, limit = 10) {
104
305
  if (!ranked.length) return "No avoidable re-renders.";
105
306
  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
307
  if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
308
+ const roots = formatRootCauses(reports, 5);
309
+ if (roots) lines.push("", "Root causes:", roots);
107
310
  return lines.join("\n");
108
311
  }
109
312
 
@@ -206,6 +409,8 @@ function checkBudget(reports, budget) {
206
409
  function toBudget(reports) {
207
410
  return { "*": 0, ...avoidableCounts(reports) };
208
411
  }
412
+ var MARKER = "__rerenderLens";
413
+ var PROTOCOL = 2;
209
414
  var MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8" };
210
415
  function findPanelDir() {
211
416
  const here = path.dirname(url.fileURLToPath(importMetaUrl));
@@ -240,26 +445,55 @@ function createRelayServer(options = {}) {
240
445
  const host = options.host ?? "127.0.0.1";
241
446
  const panelDir = options.panelDir === void 0 ? findPanelDir() : options.panelDir;
242
447
  const keepAliveMs = options.keepAliveMs ?? 15e3;
243
- const apps = /* @__PURE__ */ new Set();
448
+ const token = options.token || null;
449
+ const apps = /* @__PURE__ */ new Map();
244
450
  const panels = /* @__PURE__ */ new Set();
245
451
  let url = "";
246
- const send = (targets, message) => {
247
- const line = `data: ${JSON.stringify(message)}
452
+ let nextAppId = 1;
453
+ const connectUrl = () => token ? `${url}?token=${encodeURIComponent(token)}` : url;
454
+ const line = (message) => `data: ${JSON.stringify(message)}
248
455
 
249
456
  `;
250
- for (const res of targets) {
457
+ const sendTo = (res, message) => {
458
+ try {
459
+ res.write(line(message));
460
+ } catch {
461
+ apps.delete(res);
462
+ panels.delete(res);
463
+ }
464
+ };
465
+ const send = (targets, message) => {
466
+ const text = line(message);
467
+ for (const res of [...targets]) {
251
468
  try {
252
- res.write(line);
469
+ res.write(text);
253
470
  } catch {
254
- targets.delete(res);
471
+ apps.delete(res);
472
+ panels.delete(res);
255
473
  }
256
474
  }
257
475
  };
258
- const route = (message) => {
476
+ const appList = () => [...apps.values()];
477
+ const roster = () => ({ [MARKER]: true, version: PROTOCOL, type: "relay", payload: { apps: apps.size, list: appList() } });
478
+ const streamOf = (id) => {
479
+ for (const [res, app] of apps) if (app.id === id) return res;
480
+ return null;
481
+ };
482
+ const route = (message, from) => {
259
483
  if (!message || typeof message !== "object") return;
260
484
  const m = message;
261
- if (m.__rerenderLensCmd === true) send(apps, m);
262
- else if (m.__rerenderLens === true || m.__rerenderLensReply === true) send(panels, m);
485
+ if (m.__rerenderLensCmd === true) {
486
+ const target = typeof m.app === "string" ? streamOf(m.app) : null;
487
+ if (target) sendTo(target, m);
488
+ else send(apps.keys(), m);
489
+ } else if (m.__rerenderLens === true || m.__rerenderLensReply === true) {
490
+ send(panels, from ? { ...m, app: from.id } : m);
491
+ }
492
+ };
493
+ const authorized = (req, u) => {
494
+ if (!token) return true;
495
+ const given = u.searchParams.get("token") || (typeof req.headers["x-rerender-lens-token"] === "string" ? req.headers["x-rerender-lens-token"] : "");
496
+ return given === token;
263
497
  };
264
498
  const server = http.createServer(async (req, res) => {
265
499
  cors(res);
@@ -271,18 +505,26 @@ function createRelayServer(options = {}) {
271
505
  }
272
506
  if (u.pathname === "/" && req.method === "GET") {
273
507
  res.statusCode = 302;
274
- res.setHeader("Location", `/panel.html?relay=${encodeURIComponent(url)}`);
508
+ res.setHeader("Location", `/panel.html?relay=${encodeURIComponent(connectUrl())}`);
275
509
  res.end();
276
510
  return;
277
511
  }
512
+ if (/^\/(events|message|status)$/.test(u.pathname) && !authorized(req, u)) {
513
+ res.statusCode = 401;
514
+ res.setHeader("Content-Type", "text/plain");
515
+ res.end("rerender-lens relay: this relay needs the token from the URL it printed (?token=...)");
516
+ return;
517
+ }
278
518
  if (u.pathname === "/events" && req.method === "GET") {
279
- const role = u.searchParams.get("role") === "app" ? apps : panels;
519
+ const isApp = u.searchParams.get("role") === "app";
280
520
  res.statusCode = 200;
281
521
  res.setHeader("Content-Type", "text/event-stream");
282
522
  res.setHeader("Cache-Control", "no-store");
283
523
  res.setHeader("Connection", "keep-alive");
284
524
  res.write(": connected\n\n");
285
- role.add(res);
525
+ const app = isApp ? { id: `a${nextAppId++}`, label: (u.searchParams.get("label") || req.headers.origin || "").slice(0, 120) || `app ${nextAppId - 1}` } : null;
526
+ if (app) apps.set(res, app);
527
+ else panels.add(res);
286
528
  const ping = setInterval(() => {
287
529
  try {
288
530
  res.write(": ping\n\n");
@@ -291,18 +533,29 @@ function createRelayServer(options = {}) {
291
533
  }, keepAliveMs);
292
534
  req.on("close", () => {
293
535
  clearInterval(ping);
294
- role.delete(res);
536
+ apps.delete(res);
537
+ panels.delete(res);
295
538
  });
296
- 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 } }));
539
+ if (app) {
540
+ sendTo(res, { __rerenderLensRelay: true, version: PROTOCOL, app: app.id });
541
+ send(panels, roster());
542
+ req.on("close", () => send(panels, roster()));
543
+ } else {
544
+ sendTo(res, roster());
299
545
  }
300
546
  return;
301
547
  }
548
+ if (u.pathname === "/favicon.ico") {
549
+ res.statusCode = 204;
550
+ res.end();
551
+ return;
552
+ }
302
553
  if (u.pathname === "/message" && req.method === "POST") {
303
554
  try {
304
555
  const parsed = JSON.parse(await readBody(req) || "null");
305
- for (const m of Array.isArray(parsed) ? parsed : [parsed]) route(m);
556
+ const sender = u.searchParams.get("app");
557
+ const from = sender ? appList().find((a) => a.id === sender) ?? void 0 : void 0;
558
+ for (const m of Array.isArray(parsed) ? parsed : [parsed]) route(m, from);
306
559
  res.statusCode = 204;
307
560
  res.end();
308
561
  } catch (e) {
@@ -315,7 +568,7 @@ function createRelayServer(options = {}) {
315
568
  if (u.pathname === "/status" && req.method === "GET") {
316
569
  res.statusCode = 200;
317
570
  res.setHeader("Content-Type", "application/json");
318
- res.end(JSON.stringify({ ok: true, apps: apps.size, panels: panels.size }));
571
+ res.end(JSON.stringify({ ok: true, apps: apps.size, panels: panels.size, list: appList() }));
319
572
  return;
320
573
  }
321
574
  const file = u.pathname.replace(/^\//, "");
@@ -345,10 +598,12 @@ function createRelayServer(options = {}) {
345
598
  resolve({
346
599
  server,
347
600
  url,
601
+ connectUrl: connectUrl(),
348
602
  port,
349
603
  counts: () => ({ apps: apps.size, panels: panels.size }),
604
+ appList,
350
605
  close: () => new Promise((done) => {
351
- for (const res of [...apps, ...panels]) res.end();
606
+ for (const res of [...apps.keys(), ...panels]) res.end();
352
607
  apps.clear();
353
608
  panels.clear();
354
609
  server.close(() => done());
@@ -357,8 +612,6 @@ function createRelayServer(options = {}) {
357
612
  });
358
613
  });
359
614
  }
360
-
361
- // src/cli.ts
362
615
  var read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
363
616
  function summaryOf(file) {
364
617
  const parsed = parseExport(read(file));
@@ -366,14 +619,20 @@ function summaryOf(file) {
366
619
  if (parsed.sessions.length && !parsed.reports.length) return parsed.sessions[parsed.sessions.length - 1];
367
620
  return summarizeReports(parsed.reports, { name: file.replace(/^.*[\\/]/, "").replace(/\.json$/, "") });
368
621
  }
622
+ var isLoopback = (host) => host === "127.0.0.1" || host === "localhost" || host === "::1";
369
623
  async function panelCommand(args) {
370
624
  const flag = (name) => {
371
625
  const i = args.indexOf(name);
372
626
  return i >= 0 ? args[i + 1] ?? "" : null;
373
627
  };
374
- const relay = await createRelayServer({ port: Number(flag("--port") || 4141), host: flag("--host") || "127.0.0.1" });
375
- console.log(`rerender-lens panel: ${relay.url}/`);
376
- console.log(`in the app: createDevtoolsNotifier({ relay: '${relay.url}' }) or window.__RERENDER_LENS_RELAY__ = '${relay.url}'`);
628
+ const host = flag("--host") || "127.0.0.1";
629
+ const given = flag("--token");
630
+ const token = args.includes("--no-token") ? null : given || (isLoopback(host) ? null : randomToken());
631
+ const relay = await createRelayServer({ port: Number(flag("--port") || 4141), host, token: token ?? void 0 });
632
+ console.log(`rerender-lens panel: ${relay.connectUrl}/`);
633
+ console.log(`in the app: createDevtoolsNotifier({ relay: '${relay.connectUrl}' }) or window.__RERENDER_LENS_RELAY__ = '${relay.connectUrl}'`);
634
+ if (token) console.log("The token in that URL is required: reports carry your app's prop, state and context values.");
635
+ else if (!isLoopback(host)) console.log(`WARNING: ${host} is reachable by other machines and --no-token was given: anyone who can reach this port can read every report.`);
377
636
  console.log("Ctrl+C to stop.");
378
637
  return new Promise((resolve) => {
379
638
  const stop = () => void relay.close().then(() => resolve(0));
@@ -382,6 +641,9 @@ async function panelCommand(args) {
382
641
  relay.server.once("close", () => resolve(0));
383
642
  });
384
643
  }
644
+ function randomToken() {
645
+ return crypto.randomBytes(16).toString("base64url");
646
+ }
385
647
  function main(argv) {
386
648
  const [cmd, ...args] = argv;
387
649
  if (cmd === "panel") return panelCommand(args).catch((e) => (console.error(e.message), 1));
@@ -399,6 +661,13 @@ function main(argv) {
399
661
  console.log(formatFixes(reports, Number(flag("--limit") || 20)));
400
662
  return 0;
401
663
  }
664
+ case "causes": {
665
+ const file = positional[0];
666
+ if (!file) throw new Error("usage: rerender-lens causes <export.json> [--limit N]");
667
+ const { reports } = parseExport(read(file));
668
+ console.log(formatRootCauses(reports, Number(flag("--limit") || 10)) || "No root causes: no avoidable re-renders, or the reports carry no commit ids.");
669
+ return 0;
670
+ }
402
671
  case "summary": {
403
672
  const file = positional[0];
404
673
  if (!file) throw new Error("usage: rerender-lens summary <export.json> [--out summary.json]");
@@ -442,7 +711,7 @@ ${cmp.regressions.length} component${cmp.regressions.length === 1 ? "" : "s"} re
442
711
  return 1;
443
712
  }
444
713
  default:
445
- console.error("usage: rerender-lens <panel|fixes|summary|compare|budget> ...");
714
+ console.error("usage: rerender-lens <panel|fixes|causes|summary|compare|budget> ...");
446
715
  return cmd ? 1 : 0;
447
716
  }
448
717
  } catch (e) {