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