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.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/
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
31
|
-
const
|
|
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:
|
|
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
|
|
43
|
-
if (!
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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 (!
|
|
234
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
71
235
|
if (isContext) {
|
|
72
|
-
out.push({
|
|
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
|
-
|
|
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({
|
|
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
|
|
448
|
+
const token = options.token || null;
|
|
449
|
+
const apps = /* @__PURE__ */ new Map();
|
|
244
450
|
const panels = /* @__PURE__ */ new Set();
|
|
245
451
|
let url = "";
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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(
|
|
469
|
+
res.write(text);
|
|
253
470
|
} catch {
|
|
254
|
-
|
|
471
|
+
apps.delete(res);
|
|
472
|
+
panels.delete(res);
|
|
255
473
|
}
|
|
256
474
|
}
|
|
257
475
|
};
|
|
258
|
-
const
|
|
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)
|
|
262
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
536
|
+
apps.delete(res);
|
|
537
|
+
panels.delete(res);
|
|
295
538
|
});
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
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) {
|