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.
- package/CHANGELOG.md +127 -0
- package/README.md +208 -359
- package/dist/{budget-LkNjGtRc.d.ts → budget-CA1MS7lB.d.cts} +20 -10
- package/dist/{budget-COBu7jBU.d.cts → budget-ok1VDJ4t.d.ts} +20 -10
- package/dist/cli.cjs +322 -53
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +322 -53
- package/dist/cli.js.map +1 -1
- package/dist/{devtools-BkCct3cJ.d.cts → devtools-Cl5n_52v.d.ts} +17 -2
- package/dist/{devtools-CZebzpF6.d.ts → devtools-UwQx2VYS.d.cts} +17 -2
- package/dist/index.cjs +537 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -8
- package/dist/index.d.ts +137 -8
- package/dist/index.js +521 -50
- package/dist/index.js.map +1 -1
- package/dist/jest-setup.cjs +1690 -0
- package/dist/jest-setup.cjs.map +1 -0
- package/dist/jest-setup.d.cts +8 -0
- package/dist/jest-setup.d.ts +8 -0
- package/dist/jest-setup.js +1688 -0
- package/dist/jest-setup.js.map +1 -0
- package/dist/jest.cjs +1777 -0
- package/dist/jest.cjs.map +1 -0
- package/dist/jest.d.cts +23 -0
- package/dist/jest.d.ts +23 -0
- package/dist/jest.js +1768 -0
- package/dist/jest.js.map +1 -0
- package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-B5poAEvE.d.cts} +8 -4
- package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-CqQW0Eum.d.ts} +8 -4
- package/dist/playwright.cjs +230 -28
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +4 -4
- package/dist/playwright.d.ts +4 -4
- package/dist/playwright.js +230 -28
- package/dist/playwright.js.map +1 -1
- package/dist/relay.cjs +71 -19
- package/dist/relay.cjs.map +1 -1
- package/dist/relay.d.cts +25 -3
- package/dist/relay.d.ts +25 -3
- package/dist/relay.js +71 -19
- package/dist/relay.js.map +1 -1
- package/dist/rerender-lens.iife.js +457 -62
- package/dist/runner-report-Bvqoe89M.d.ts +46 -0
- package/dist/runner-report-CvpDg2zt.d.cts +46 -0
- package/dist/setup.cjs +209 -35
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +209 -35
- package/dist/setup.js.map +1 -1
- package/dist/{types-BzUEVkxJ.d.cts → types-DwNN5cWe.d.cts} +82 -2
- package/dist/{types-BzUEVkxJ.d.ts → types-DwNN5cWe.d.ts} +82 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vitest-setup.cjs +357 -44
- package/dist/vitest-setup.cjs.map +1 -1
- package/dist/vitest-setup.d.cts +4 -4
- package/dist/vitest-setup.d.ts +4 -4
- package/dist/vitest-setup.js +357 -44
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +420 -80
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +21 -39
- package/dist/vitest.d.ts +21 -39
- package/dist/vitest.js +421 -81
- package/dist/vitest.js.map +1 -1
- package/package.json +41 -4
- package/panel/panel.css +49 -0
- 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/
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
25
|
-
const
|
|
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:
|
|
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
|
|
37
|
-
if (!
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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 (!
|
|
228
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
65
229
|
if (isContext) {
|
|
66
|
-
out.push({
|
|
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
|
-
|
|
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({
|
|
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
|
|
442
|
+
const token = options.token || null;
|
|
443
|
+
const apps = /* @__PURE__ */ new Map();
|
|
238
444
|
const panels = /* @__PURE__ */ new Set();
|
|
239
445
|
let url = "";
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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(
|
|
463
|
+
res.write(text);
|
|
247
464
|
} catch {
|
|
248
|
-
|
|
465
|
+
apps.delete(res);
|
|
466
|
+
panels.delete(res);
|
|
249
467
|
}
|
|
250
468
|
}
|
|
251
469
|
};
|
|
252
|
-
const
|
|
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)
|
|
256
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
530
|
+
apps.delete(res);
|
|
531
|
+
panels.delete(res);
|
|
289
532
|
});
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
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) {
|