rerender-lens 0.2.0 → 0.4.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 +110 -1
- package/README.md +159 -9
- package/dist/budget-COBu7jBU.d.cts +64 -0
- package/dist/budget-LkNjGtRc.d.ts +64 -0
- package/dist/cli.cjs +458 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +452 -0
- package/dist/cli.js.map +1 -0
- package/dist/devtools-BkCct3cJ.d.cts +252 -0
- package/dist/devtools-CZebzpF6.d.ts +252 -0
- package/dist/index.cjs +832 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -336
- package/dist/index.d.ts +40 -336
- package/dist/index.js +816 -76
- package/dist/index.js.map +1 -1
- package/dist/notifiers-BGQtWKfX.d.cts +90 -0
- package/dist/notifiers-BjjGSHpp.d.ts +90 -0
- package/dist/playwright.cjs +199 -0
- package/dist/playwright.cjs.map +1 -0
- package/dist/playwright.d.cts +37 -0
- package/dist/playwright.d.ts +37 -0
- package/dist/playwright.js +186 -0
- package/dist/playwright.js.map +1 -0
- package/dist/relay.cjs +166 -0
- package/dist/relay.cjs.map +1 -0
- package/dist/relay.d.cts +41 -0
- package/dist/relay.d.ts +41 -0
- package/dist/relay.js +161 -0
- package/dist/relay.js.map +1 -0
- package/dist/rerender-lens.iife.js +2178 -0
- package/dist/setup.cjs +1638 -0
- package/dist/setup.cjs.map +1 -0
- package/dist/setup.d.cts +2 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +1636 -0
- package/dist/setup.js.map +1 -0
- package/dist/types-BzUEVkxJ.d.cts +177 -0
- package/dist/types-BzUEVkxJ.d.ts +177 -0
- package/dist/vite.cjs +113 -0
- package/dist/vite.cjs.map +1 -0
- package/dist/vite.d.cts +84 -0
- package/dist/vite.d.ts +84 -0
- package/dist/vite.js +103 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-setup.cjs +1368 -0
- package/dist/vitest-setup.cjs.map +1 -0
- package/dist/vitest-setup.d.cts +8 -0
- package/dist/vitest-setup.d.ts +8 -0
- package/dist/vitest-setup.js +1366 -0
- package/dist/vitest-setup.js.map +1 -0
- package/dist/vitest.cjs +1429 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +52 -0
- package/dist/vitest.d.ts +52 -0
- package/dist/vitest.js +1420 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +84 -4
- package/panel/panel.css +384 -0
- package/panel/panel.html +12 -0
- package/panel/panel.js +3287 -0
package/panel/panel.js
ADDED
|
@@ -0,0 +1,3287 @@
|
|
|
1
|
+
/* Built from extension/src/panel.ts by `npm run build`; do not edit by hand. */
|
|
2
|
+
"use strict";
|
|
3
|
+
(() => {
|
|
4
|
+
// extension/src/panel.ts
|
|
5
|
+
var PROTOCOL = 2;
|
|
6
|
+
var KIND_LABEL = {
|
|
7
|
+
"deep-equal": "equal by value",
|
|
8
|
+
function: "new function",
|
|
9
|
+
element: "equal element",
|
|
10
|
+
different: "changed",
|
|
11
|
+
added: "added",
|
|
12
|
+
removed: "removed"
|
|
13
|
+
};
|
|
14
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
15
|
+
var FN_PREFIX = "\u0192 ";
|
|
16
|
+
var MAX_REPORTS = 2e3;
|
|
17
|
+
var MAX_PER_NODE = 200;
|
|
18
|
+
var MAX_COMMITS = 500;
|
|
19
|
+
var MAX_PER_COMMIT = 500;
|
|
20
|
+
var THROTTLE_MIN_REPORTS = 200;
|
|
21
|
+
var BEST_FIX_INTERVAL = 500;
|
|
22
|
+
var LEFT_RENDER_INTERVAL = 250;
|
|
23
|
+
var SYNC_RETRY_MIN = 500;
|
|
24
|
+
var SYNC_RETRY_MAX = 5e3;
|
|
25
|
+
var SYNC_RETRIES = 20;
|
|
26
|
+
var NAVIGATION_SETTLE = 1200;
|
|
27
|
+
var ROW_H = 22;
|
|
28
|
+
var ITEM_H = 20;
|
|
29
|
+
var OVERSCAN = 8;
|
|
30
|
+
var FALLBACK_VIEWPORT = 800;
|
|
31
|
+
function el(tag, attrs, children) {
|
|
32
|
+
const node = document.createElement(tag);
|
|
33
|
+
if (attrs) {
|
|
34
|
+
for (const k of Object.keys(attrs)) {
|
|
35
|
+
const v = attrs[k];
|
|
36
|
+
if (k === "class") node.className = String(v);
|
|
37
|
+
else if (k === "text") node.textContent = String(v);
|
|
38
|
+
else if (k.startsWith("on")) {
|
|
39
|
+
if (typeof v === "function") node.addEventListener(k.slice(2), v);
|
|
40
|
+
} else if (v === true) node.setAttribute(k, "");
|
|
41
|
+
else if (v !== void 0 && v !== null && v !== false) node.setAttribute(k, String(v));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (children) {
|
|
45
|
+
for (const c of [].concat(children)) if (c != null) node.append(c);
|
|
46
|
+
}
|
|
47
|
+
return node;
|
|
48
|
+
}
|
|
49
|
+
function fmtTime(ms) {
|
|
50
|
+
const d = new Date(ms);
|
|
51
|
+
const p = (n, w) => String(n).padStart(w, "0");
|
|
52
|
+
return `${p(d.getHours(), 2)}:${p(d.getMinutes(), 2)}:${p(d.getSeconds(), 2)}.${p(d.getMilliseconds(), 3)}`;
|
|
53
|
+
}
|
|
54
|
+
var fmtMs = (n) => typeof n === "number" && Number.isFinite(n) ? `${n.toFixed(1)} ms` : "";
|
|
55
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
56
|
+
var componentList = (m) => [...m].map(([c, n]) => `<${c}>${n > 1 ? " \xD7" + n : ""}`).join(", ");
|
|
57
|
+
function changesOf(report) {
|
|
58
|
+
return [].concat(report.propChanges || [], report.stateChanges || [], report.hookChanges || []);
|
|
59
|
+
}
|
|
60
|
+
function summarize(report) {
|
|
61
|
+
const counts = /* @__PURE__ */ new Map();
|
|
62
|
+
for (const c of changesOf(report)) counts.set(c.kind, (counts.get(c.kind) || 0) + 1);
|
|
63
|
+
if (counts.size === 0) return "no changes";
|
|
64
|
+
return [...counts].map(([k, n]) => `${n} ${KIND_LABEL[k] || k}`).join(", ");
|
|
65
|
+
}
|
|
66
|
+
var isRecord = (v) => typeof v === "object" && v !== null;
|
|
67
|
+
function normalizeReport(p) {
|
|
68
|
+
if (!isRecord(p) || typeof p.component !== "string") return null;
|
|
69
|
+
const arr = (x) => Array.isArray(x) ? x.filter((c) => isRecord(c) && typeof c.path === "string") : [];
|
|
70
|
+
const props = isRecord(p.props) ? p.props : {};
|
|
71
|
+
const parent = isRecord(p.parent) && typeof p.parent.name === "string" ? { name: p.parent.name, trigger: typeof p.parent.trigger === "string" ? p.parent.trigger : "parent" } : null;
|
|
72
|
+
const r = {
|
|
73
|
+
component: p.component,
|
|
74
|
+
instanceId: typeof p.instanceId === "number" ? p.instanceId : 0,
|
|
75
|
+
commitId: typeof p.commitId === "number" ? p.commitId : 0,
|
|
76
|
+
renderCount: typeof p.renderCount === "number" ? p.renderCount : 0,
|
|
77
|
+
trigger: typeof p.trigger === "string" ? p.trigger : "parent",
|
|
78
|
+
avoidable: !!p.avoidable,
|
|
79
|
+
props: { prev: isRecord(props.prev) ? props.prev : {}, next: isRecord(props.next) ? props.next : {} },
|
|
80
|
+
propChanges: arr(p.propChanges),
|
|
81
|
+
stateChanges: arr(p.stateChanges),
|
|
82
|
+
hookChanges: arr(p.hookChanges),
|
|
83
|
+
parent,
|
|
84
|
+
owner: typeof p.owner === "string" ? p.owner : null,
|
|
85
|
+
path: Array.isArray(p.path) ? p.path.filter((x) => typeof x === "string") : [],
|
|
86
|
+
reasons: Array.isArray(p.reasons) ? p.reasons.filter((x) => typeof x === "string") : [],
|
|
87
|
+
time: typeof p.time === "number" ? p.time : 0,
|
|
88
|
+
receivedAt: typeof p.receivedAt === "number" ? p.receivedAt : 0
|
|
89
|
+
};
|
|
90
|
+
if (typeof p.selfDuration === "number") r.selfDuration = p.selfDuration;
|
|
91
|
+
if (typeof p.treeDuration === "number") r.treeDuration = p.treeDuration;
|
|
92
|
+
if (typeof p.memoized === "boolean") r.memoized = p.memoized;
|
|
93
|
+
if (typeof p.commitPriority === "string") r.commitPriority = p.commitPriority;
|
|
94
|
+
if (Array.isArray(p.hookState)) r.hookState = p.hookState.filter((h) => isRecord(h) && typeof h.path === "string");
|
|
95
|
+
if (Array.isArray(p.contexts)) r.contexts = p.contexts.filter((c) => isRecord(c) && typeof c.name === "string");
|
|
96
|
+
if (isRecord(p.state)) r.state = p.state;
|
|
97
|
+
if (Array.isArray(p.updaters)) r.updaters = p.updaters.filter((u) => typeof u === "string");
|
|
98
|
+
if (p.commitCause === "effect-after-commit" || p.commitCause === "suspense-resolved") r.commitCause = p.commitCause;
|
|
99
|
+
if (typeof p.afterCommit === "number") r.afterCommit = p.afterCommit;
|
|
100
|
+
if (typeof p.key === "string") r.key = p.key;
|
|
101
|
+
if (isRecord(p.source) && typeof p.source.fileName === "string") r.source = p.source;
|
|
102
|
+
return r;
|
|
103
|
+
}
|
|
104
|
+
function valueNode(v, depth = 0) {
|
|
105
|
+
if (v === null || v === void 0) return el("span", { class: "v nil", text: String(v) });
|
|
106
|
+
const t = typeof v;
|
|
107
|
+
if (typeof v === "string") {
|
|
108
|
+
if (v.startsWith(FN_PREFIX)) return el("span", { class: "v fn", text: v });
|
|
109
|
+
if (/^<[^>]+>$/.test(v)) return el("span", { class: "v", text: v });
|
|
110
|
+
return el("span", { class: "v str", text: JSON.stringify(v) });
|
|
111
|
+
}
|
|
112
|
+
if (t === "number" || t === "bigint") return el("span", { class: "v num", text: String(v) });
|
|
113
|
+
if (t === "boolean") return el("span", { class: "v bool", text: String(v) });
|
|
114
|
+
if (Array.isArray(v)) {
|
|
115
|
+
const short = v.length <= 4 && v.every((x) => typeof x !== "object" || x === null);
|
|
116
|
+
if (short) {
|
|
117
|
+
const s = el("span", { class: "v" }, "[");
|
|
118
|
+
v.forEach((x, i) => {
|
|
119
|
+
if (i) s.append(", ");
|
|
120
|
+
s.append(valueNode(x, depth + 1));
|
|
121
|
+
});
|
|
122
|
+
s.append("]");
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
return objectDetails(`Array(${v.length})`, v);
|
|
126
|
+
}
|
|
127
|
+
const o = v;
|
|
128
|
+
if (o.$type === "element") {
|
|
129
|
+
const props = isRecord(o.props) ? o.props : {};
|
|
130
|
+
const label2 = `<${String(o.name)}${o.key !== void 0 ? ` key=${JSON.stringify(o.key)}` : ""}>`;
|
|
131
|
+
return Object.keys(props).length ? objectDetails(label2, props) : el("span", { class: "v", text: label2 });
|
|
132
|
+
}
|
|
133
|
+
if (o.$type === "Date") return el("span", { class: "v", text: `Date(${String(o.value)})` });
|
|
134
|
+
if (o.$type === "RegExp") return el("span", { class: "v", text: String(o.value) });
|
|
135
|
+
if (o.$type === "Map" && Array.isArray(o.entries)) return objectDetails(`Map(${o.entries.length})`, o.entries);
|
|
136
|
+
if (o.$type === "Set" && Array.isArray(o.values)) return objectDetails(`Set(${o.values.length})`, o.values);
|
|
137
|
+
const keys = Object.keys(o).filter((k) => k !== "$type");
|
|
138
|
+
const label = `${o.$type ? String(o.$type) + " " : ""}{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", ..." : ""}}`;
|
|
139
|
+
return objectDetails(label, o);
|
|
140
|
+
}
|
|
141
|
+
function objectDetails(label, obj) {
|
|
142
|
+
const d = el("details", { class: "obj" }, [el("summary", { text: label })]);
|
|
143
|
+
d.addEventListener(
|
|
144
|
+
"toggle",
|
|
145
|
+
() => {
|
|
146
|
+
if (d.open && !d.querySelector("pre")) d.append(el("pre", { text: JSON.stringify(obj, null, 2) }));
|
|
147
|
+
},
|
|
148
|
+
{ once: true }
|
|
149
|
+
);
|
|
150
|
+
return d;
|
|
151
|
+
}
|
|
152
|
+
function firstDifferentPath(a, b, base = "") {
|
|
153
|
+
if (a === b) return null;
|
|
154
|
+
if (!isRecord(a) || !isRecord(b)) return base || "(value)";
|
|
155
|
+
if (Array.isArray(a) !== Array.isArray(b)) return base || "(value)";
|
|
156
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
157
|
+
if (a.length !== b.length) return base ? `${base}.length` : "length";
|
|
158
|
+
for (let i = 0; i < a.length; i++) {
|
|
159
|
+
const p = firstDifferentPath(a[i], b[i], `${base}[${i}]`);
|
|
160
|
+
if (p) return p;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
165
|
+
for (const k of keys) {
|
|
166
|
+
const p = firstDifferentPath(a[k], b[k], base ? `${base}.${k}` : k);
|
|
167
|
+
if (p) return p;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function diffLeaves(a, b, limit = 20, base = "", out = []) {
|
|
172
|
+
if (out.length >= limit || a === b) return out;
|
|
173
|
+
if (!isRecord(a) || !isRecord(b) || Array.isArray(a) !== Array.isArray(b)) {
|
|
174
|
+
out.push({ path: base || "(value)", prev: a, next: b });
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
178
|
+
const n = Math.max(a.length, b.length);
|
|
179
|
+
for (let i = 0; i < n && out.length < limit; i++) diffLeaves(a[i], b[i], limit, `${base}[${i}]`, out);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
183
|
+
if (out.length >= limit) break;
|
|
184
|
+
diffLeaves(a[k], b[k], limit, base ? `${base}.${k}` : k, out);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function shortValue(v, max = 60) {
|
|
189
|
+
let s;
|
|
190
|
+
try {
|
|
191
|
+
s = JSON.stringify(v);
|
|
192
|
+
} catch {
|
|
193
|
+
s = String(v);
|
|
194
|
+
}
|
|
195
|
+
if (s === void 0) s = String(v);
|
|
196
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
197
|
+
}
|
|
198
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
199
|
+
function fixesFor(r) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const ownerName = r.owner || r.parent && r.parent.name || null;
|
|
202
|
+
const changes = changesOf(r);
|
|
203
|
+
const avoidableProps = (r.propChanges || []).filter((c) => AVOIDABLE_KINDS.has(c.kind));
|
|
204
|
+
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
205
|
+
const identical = changes.length === 0;
|
|
206
|
+
out.push({
|
|
207
|
+
kind: "memo",
|
|
208
|
+
owner: r.component,
|
|
209
|
+
target: r.component,
|
|
210
|
+
prop: null,
|
|
211
|
+
label: `Wrap <${r.component}> in React.memo`,
|
|
212
|
+
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.`,
|
|
213
|
+
snippet: `// ${r.component}
|
|
214
|
+
import { memo } from 'react';
|
|
215
|
+
|
|
216
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
217
|
+
// ...
|
|
218
|
+
});
|
|
219
|
+
// class components: extend PureComponent instead`
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (const c of avoidableProps) {
|
|
223
|
+
const owner = ownerName || "?";
|
|
224
|
+
const root = c.path.split(/[.[]/)[0] || c.path;
|
|
225
|
+
const id = identifier(root);
|
|
226
|
+
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
227
|
+
out.push({
|
|
228
|
+
kind: "children",
|
|
229
|
+
owner,
|
|
230
|
+
target: r.component,
|
|
231
|
+
prop: "children",
|
|
232
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
233
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
234
|
+
snippet: `// ${owner}
|
|
235
|
+
import { useMemo } from 'react';
|
|
236
|
+
|
|
237
|
+
const children = useMemo(() => (
|
|
238
|
+
<>{/* the same elements */}</>
|
|
239
|
+
), [/* deps */]);
|
|
240
|
+
|
|
241
|
+
<${r.component}>{children}</${r.component}>
|
|
242
|
+
|
|
243
|
+
// static children: hoist them to module scope
|
|
244
|
+
const STATIC = <em>hi</em>;`
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (c.kind === "function") {
|
|
249
|
+
out.push({
|
|
250
|
+
kind: "useCallback",
|
|
251
|
+
owner,
|
|
252
|
+
target: r.component,
|
|
253
|
+
prop: root,
|
|
254
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
255
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
256
|
+
snippet: `// ${owner}
|
|
257
|
+
import { useCallback } from 'react';
|
|
258
|
+
|
|
259
|
+
const ${id} = useCallback((/* args */) => {
|
|
260
|
+
// ...
|
|
261
|
+
}, [/* deps */]);
|
|
262
|
+
|
|
263
|
+
<${r.component} ${root}={${id}} />`
|
|
264
|
+
});
|
|
265
|
+
} else if (c.kind === "element") {
|
|
266
|
+
out.push({
|
|
267
|
+
kind: "useMemoElement",
|
|
268
|
+
owner,
|
|
269
|
+
target: r.component,
|
|
270
|
+
prop: root,
|
|
271
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
272
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
273
|
+
snippet: `// ${owner}
|
|
274
|
+
import { useMemo } from 'react';
|
|
275
|
+
|
|
276
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
277
|
+
// or pass it as children from a component that does not re-render`
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
const isArray = Array.isArray(c.next);
|
|
281
|
+
out.push({
|
|
282
|
+
kind: "useMemo",
|
|
283
|
+
owner,
|
|
284
|
+
target: r.component,
|
|
285
|
+
prop: root,
|
|
286
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
287
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
288
|
+
snippet: `// ${owner}
|
|
289
|
+
import { useMemo } from 'react';
|
|
290
|
+
|
|
291
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
292
|
+
|
|
293
|
+
// or, when it never changes, hoist it to module scope:
|
|
294
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const c of [].concat(r.stateChanges || [], r.hookChanges || [])) {
|
|
299
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
300
|
+
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
301
|
+
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
302
|
+
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
303
|
+
out.push({
|
|
304
|
+
kind: "splitContext",
|
|
305
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
306
|
+
target: r.component,
|
|
307
|
+
prop: ctxName,
|
|
308
|
+
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
309
|
+
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.`,
|
|
310
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
311
|
+
// one context per independently-changing slice
|
|
312
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
313
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
314
|
+
|
|
315
|
+
// or keep one context and let consumers select a slice:
|
|
316
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
321
|
+
if (isContext) {
|
|
322
|
+
const name = ctxName;
|
|
323
|
+
out.push({
|
|
324
|
+
kind: "contextValue",
|
|
325
|
+
owner: providerOwner || `${name}.Provider`,
|
|
326
|
+
target: r.component,
|
|
327
|
+
prop: name,
|
|
328
|
+
label: `memoize the ${name} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
329
|
+
detail: `<${r.component}> re-rendered because ${name} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
330
|
+
snippet: `// ${providerOwner || `where <${name}.Provider> is rendered`}
|
|
331
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
332
|
+
<${name}.Provider value={value}>`
|
|
333
|
+
});
|
|
334
|
+
} else if (c.hook === "useSyncExternalStore") {
|
|
335
|
+
const chain = c.custom || [];
|
|
336
|
+
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
337
|
+
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
338
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
339
|
+
out.push({
|
|
340
|
+
kind: "storeSnapshot",
|
|
341
|
+
owner: r.component,
|
|
342
|
+
target: r.component,
|
|
343
|
+
prop: c.path,
|
|
344
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
345
|
+
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.`,
|
|
346
|
+
snippet: redux ? `// ${r.component}
|
|
347
|
+
import { shallowEqual } from 'react-redux';
|
|
348
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
349
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
350
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
351
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
352
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
353
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
354
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
out.push({
|
|
358
|
+
kind: "bailout",
|
|
359
|
+
owner: r.component,
|
|
360
|
+
target: r.component,
|
|
361
|
+
prop: c.path,
|
|
362
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
363
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
364
|
+
snippet: `// ${r.component}
|
|
365
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
var fixKey = (f) => `${f.kind}|${f.owner}|${f.prop || f.target}`;
|
|
372
|
+
function rankFixes(reports) {
|
|
373
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
374
|
+
for (const r of reports) {
|
|
375
|
+
if (!r.avoidable) continue;
|
|
376
|
+
for (const f of fixesFor(r)) {
|
|
377
|
+
const k = fixKey(f);
|
|
378
|
+
let agg = byKey.get(k);
|
|
379
|
+
if (!agg) {
|
|
380
|
+
agg = { ...f, key: k, count: 0, components: /* @__PURE__ */ new Map(), reports: [] };
|
|
381
|
+
byKey.set(k, agg);
|
|
382
|
+
}
|
|
383
|
+
agg.count++;
|
|
384
|
+
agg.components.set(r.component, (agg.components.get(r.component) || 0) + 1);
|
|
385
|
+
if (agg.reports.length < 50) agg.reports.push(r);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
389
|
+
}
|
|
390
|
+
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);
|
|
391
|
+
function indexByComponent(reports) {
|
|
392
|
+
const index = /* @__PURE__ */ new Map();
|
|
393
|
+
for (const r of reports) {
|
|
394
|
+
const list = index.get(r.component);
|
|
395
|
+
if (list) list.push(r);
|
|
396
|
+
else index.set(r.component, [r]);
|
|
397
|
+
}
|
|
398
|
+
return index;
|
|
399
|
+
}
|
|
400
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
401
|
+
let cur = r;
|
|
402
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
403
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
404
|
+
const parent = cur.parent;
|
|
405
|
+
const candidates = index.get(parent.name);
|
|
406
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
407
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
408
|
+
seen.add(p);
|
|
409
|
+
cur = p;
|
|
410
|
+
}
|
|
411
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
412
|
+
}
|
|
413
|
+
function analyzeCommit(reports) {
|
|
414
|
+
const roots = /* @__PURE__ */ new Map();
|
|
415
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
416
|
+
const index = indexByComponent(reports);
|
|
417
|
+
let avoidable = 0;
|
|
418
|
+
let wasted = 0;
|
|
419
|
+
for (const r of reports) {
|
|
420
|
+
if (!r.avoidable) continue;
|
|
421
|
+
avoidable++;
|
|
422
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
423
|
+
const root = rootCauseOf(r, reports, index);
|
|
424
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
425
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
426
|
+
rootByReport.set(r, name);
|
|
427
|
+
let agg = roots.get(name);
|
|
428
|
+
if (!agg) {
|
|
429
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
430
|
+
roots.set(name, agg);
|
|
431
|
+
}
|
|
432
|
+
agg.count++;
|
|
433
|
+
agg.components.set(r.component, (agg.components.get(r.component) || 0) + 1);
|
|
434
|
+
}
|
|
435
|
+
const first = reports[0];
|
|
436
|
+
return {
|
|
437
|
+
id: first ? first.commitId : 0,
|
|
438
|
+
receivedAt: first ? first.receivedAt : 0,
|
|
439
|
+
total: reports.length,
|
|
440
|
+
avoidable,
|
|
441
|
+
wasted,
|
|
442
|
+
roots: [...roots.values()].sort((a, b) => b.count - a.count),
|
|
443
|
+
rootByReport,
|
|
444
|
+
contexts: contextAttribution(reports),
|
|
445
|
+
fixes: rankFixes(reports),
|
|
446
|
+
reports
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function contextAttribution(reports) {
|
|
450
|
+
const byCtx = /* @__PURE__ */ new Map();
|
|
451
|
+
for (const r of reports) {
|
|
452
|
+
for (const c of r.hookChanges || []) {
|
|
453
|
+
if (c.hook !== "useContext" && !/^useContext/.test(c.path)) continue;
|
|
454
|
+
const m = /useContext\((.*)\)/.exec(c.path);
|
|
455
|
+
const name = m && m[1] ? m[1] : c.path;
|
|
456
|
+
let agg = byCtx.get(name);
|
|
457
|
+
if (!agg) {
|
|
458
|
+
agg = { name, consumers: 0, avoidable: 0, components: /* @__PURE__ */ new Map(), commits: /* @__PURE__ */ new Set(), providers: /* @__PURE__ */ new Map(), changedKeys: /* @__PURE__ */ new Set(), totalKeys: 0 };
|
|
459
|
+
byCtx.set(name, agg);
|
|
460
|
+
}
|
|
461
|
+
agg.consumers++;
|
|
462
|
+
if (AVOIDABLE_KINDS.has(c.kind)) agg.avoidable++;
|
|
463
|
+
agg.components.set(r.component, (agg.components.get(r.component) || 0) + 1);
|
|
464
|
+
agg.commits.add(r.commitId);
|
|
465
|
+
if (c.provider && c.provider.component) agg.providers.set(c.provider.component, (agg.providers.get(c.provider.component) || 0) + 1);
|
|
466
|
+
if (c.changedKeys) for (const k of c.changedKeys) agg.changedKeys.add(k);
|
|
467
|
+
if (typeof c.totalKeys === "number") agg.totalKeys = Math.max(agg.totalKeys, c.totalKeys);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return [...byCtx.values()].sort((a, b) => b.consumers - a.consumers);
|
|
471
|
+
}
|
|
472
|
+
function cascadeTree(reports) {
|
|
473
|
+
const root = { name: "", children: /* @__PURE__ */ new Map(), report: null };
|
|
474
|
+
for (const r of reports) {
|
|
475
|
+
let node = root;
|
|
476
|
+
for (const seg of r.path.concat([r.component])) {
|
|
477
|
+
let next = node.children.get(seg);
|
|
478
|
+
if (!next) {
|
|
479
|
+
next = { name: seg, children: /* @__PURE__ */ new Map(), report: null };
|
|
480
|
+
node.children.set(seg, next);
|
|
481
|
+
}
|
|
482
|
+
node = next;
|
|
483
|
+
}
|
|
484
|
+
if (!node.report || r.avoidable) node.report = r;
|
|
485
|
+
node.count = (node.count || 0) + 1;
|
|
486
|
+
if (r.avoidable) node.avoidable = (node.avoidable || 0) + 1;
|
|
487
|
+
}
|
|
488
|
+
return root;
|
|
489
|
+
}
|
|
490
|
+
function rootCauseSummary(name, commits, analyze = (_, reports) => analyzeCommit(reports)) {
|
|
491
|
+
const out = { name, trigger: "parent", commits: [], total: 0, components: /* @__PURE__ */ new Map(), fixes: [] };
|
|
492
|
+
const affected = [];
|
|
493
|
+
for (const [key, reports] of commits) {
|
|
494
|
+
const analysis = analyze(key, reports);
|
|
495
|
+
const root = analysis.roots.find((x) => x.name === name);
|
|
496
|
+
if (!root) continue;
|
|
497
|
+
out.trigger = root.trigger;
|
|
498
|
+
out.commits.push({ key, analysis, count: root.count, components: root.components });
|
|
499
|
+
out.total += root.count;
|
|
500
|
+
for (const [c, n] of root.components) out.components.set(c, (out.components.get(c) || 0) + n);
|
|
501
|
+
for (const r of reports) if (r.avoidable && analysis.rootByReport.get(r) === name) affected.push(r);
|
|
502
|
+
}
|
|
503
|
+
out.commits.reverse();
|
|
504
|
+
out.fixes = rankFixes(affected);
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
function summarizeSession(session, reports) {
|
|
508
|
+
var _a;
|
|
509
|
+
const byComponent = {};
|
|
510
|
+
let avoidable = 0;
|
|
511
|
+
let wasted = 0;
|
|
512
|
+
for (const r of reports) {
|
|
513
|
+
const c = byComponent[_a = r.component] || (byComponent[_a] = { total: 0, avoidable: 0, wasted: 0 });
|
|
514
|
+
c.total++;
|
|
515
|
+
if (r.avoidable) {
|
|
516
|
+
c.avoidable++;
|
|
517
|
+
avoidable++;
|
|
518
|
+
if (typeof r.selfDuration === "number") {
|
|
519
|
+
c.wasted += r.selfDuration;
|
|
520
|
+
wasted += r.selfDuration;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
id: session.id,
|
|
526
|
+
name: session.name,
|
|
527
|
+
startedAt: session.startedAt,
|
|
528
|
+
endedAt: session.endedAt,
|
|
529
|
+
total: reports.length,
|
|
530
|
+
avoidable,
|
|
531
|
+
wasted,
|
|
532
|
+
byComponent,
|
|
533
|
+
fixes: rankFixes(reports).map((f) => ({ key: f.key, label: f.label, count: f.count }))
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function compareSessions(before, after) {
|
|
537
|
+
const names = /* @__PURE__ */ new Set([...Object.keys(before.byComponent), ...Object.keys(after.byComponent)]);
|
|
538
|
+
const rows = [];
|
|
539
|
+
for (const component of names) {
|
|
540
|
+
const b = before.byComponent[component]?.avoidable || 0;
|
|
541
|
+
const a = after.byComponent[component]?.avoidable || 0;
|
|
542
|
+
if (b || a) rows.push({ component, before: b, after: a, delta: a - b });
|
|
543
|
+
}
|
|
544
|
+
rows.sort((x, y) => x.delta - y.delta || y.before - x.before || x.component.localeCompare(y.component));
|
|
545
|
+
const afterKeys = new Set(after.fixes.map((f) => f.key));
|
|
546
|
+
const beforeKeys = new Set(before.fixes.map((f) => f.key));
|
|
547
|
+
return {
|
|
548
|
+
before,
|
|
549
|
+
after,
|
|
550
|
+
rows,
|
|
551
|
+
total: { before: before.total, after: after.total, delta: after.total - before.total },
|
|
552
|
+
avoidable: { before: before.avoidable, after: after.avoidable, delta: after.avoidable - before.avoidable },
|
|
553
|
+
wasted: { before: before.wasted, after: after.wasted, delta: after.wasted - before.wasted },
|
|
554
|
+
resolvedFixes: before.fixes.filter((f) => !afterKeys.has(f.key)),
|
|
555
|
+
newFixes: after.fixes.filter((f) => !beforeKeys.has(f.key))
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
var PRIORITY_LABEL = { immediate: "discrete input", "user-blocking": "continuous input", normal: "transition / async", low: "low", idle: "idle" };
|
|
559
|
+
function sourceContext(text, line, around = 3) {
|
|
560
|
+
const lines = text.split("\n");
|
|
561
|
+
const from = Math.max(1, line - around);
|
|
562
|
+
const to = Math.min(lines.length, line + around);
|
|
563
|
+
const out = [];
|
|
564
|
+
for (let n = from; n <= to; n++) out.push({ n, text: lines[n - 1] ?? "", hit: n === line });
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
function reportToMarkdown(r) {
|
|
568
|
+
const lines = [];
|
|
569
|
+
lines.push(`### <${r.component}> ${r.avoidable ? "avoidable re-render" : `re-render (${r.trigger})`} #${r.renderCount}`);
|
|
570
|
+
lines.push("");
|
|
571
|
+
for (const x of r.reasons || []) lines.push(`- ${x}`);
|
|
572
|
+
if (r.path && r.path.length) lines.push("", `**Path:** ${r.path.concat([r.component]).join(" > ")}`);
|
|
573
|
+
if (r.parent) lines.push(`**Triggered by:** <${r.parent.name}> (${r.parent.trigger})`);
|
|
574
|
+
if (r.owner) lines.push(`**Created by:** <${r.owner}>`);
|
|
575
|
+
if (r.source) lines.push(`**Source:** ${r.source.fileName}${r.source.lineNumber ? ":" + r.source.lineNumber : ""}`);
|
|
576
|
+
const changes = changesOf(r);
|
|
577
|
+
if (changes.length) {
|
|
578
|
+
lines.push("", "| path | kind | prev | next |", "| --- | --- | --- | --- |");
|
|
579
|
+
for (const c of changes) lines.push(`| ${c.path} | ${KIND_LABEL[c.kind] || c.kind} | \`${shortValue(c.prev, 40)}\` | \`${shortValue(c.next, 40)}\` |`);
|
|
580
|
+
}
|
|
581
|
+
if (r.hookState && r.hookState.length) {
|
|
582
|
+
lines.push("", "**Hooks**", "");
|
|
583
|
+
for (const h of r.hookState) lines.push(`- ${h.path}: \`${shortValue(h.value, 60)}\``);
|
|
584
|
+
}
|
|
585
|
+
if (r.state && Object.keys(r.state).length) lines.push("", `**State:** \`${shortValue(r.state, 120)}\``);
|
|
586
|
+
if (r.contexts && r.contexts.length) {
|
|
587
|
+
lines.push("", "**Contexts**", "");
|
|
588
|
+
for (const c of r.contexts) lines.push(`- ${c.name}: \`${shortValue(c.value, 60)}\``);
|
|
589
|
+
}
|
|
590
|
+
const fixes = fixesFor(r);
|
|
591
|
+
if (fixes.length) {
|
|
592
|
+
lines.push("", "**Fix**", "");
|
|
593
|
+
for (const f of fixes) lines.push(`- ${f.label}`);
|
|
594
|
+
lines.push("", "```jsx", fixes[0].snippet, "```");
|
|
595
|
+
}
|
|
596
|
+
return lines.join("\n");
|
|
597
|
+
}
|
|
598
|
+
function changeRow(label, c, suffix = "") {
|
|
599
|
+
const tr = el("tr", { class: "changed" + (AVOIDABLE_KINDS.has(c.kind) ? "" : " real") });
|
|
600
|
+
tr.append(el("td", { class: "k", text: label }));
|
|
601
|
+
const td = el("td");
|
|
602
|
+
td.append(valueNode(c.prev), el("span", { class: "arrow", text: "\u2192" }));
|
|
603
|
+
td.append(c.kind === "removed" ? el("span", { class: "v nil", text: "(removed)" }) : valueNode(c.next));
|
|
604
|
+
let kind = (KIND_LABEL[c.kind] || c.kind) + suffix;
|
|
605
|
+
const objectDiff = c.kind === "different" && isRecord(c.prev) && isRecord(c.next);
|
|
606
|
+
if (objectDiff) {
|
|
607
|
+
const p = firstDifferentPath(c.prev, c.next, c.path);
|
|
608
|
+
if (p) kind += ` at ${p}`;
|
|
609
|
+
}
|
|
610
|
+
td.append(el("span", { class: "kind", text: kind }));
|
|
611
|
+
if (objectDiff) {
|
|
612
|
+
const leaves = diffLeaves(c.prev, c.next, 20, c.path);
|
|
613
|
+
if (leaves.length) {
|
|
614
|
+
const d = el("details", { class: "diff" }, [el("summary", { text: `${leaves.length}${leaves.length >= 20 ? "+" : ""} differing ${leaves.length === 1 ? "leaf" : "leaves"}` })]);
|
|
615
|
+
const table = el("table", { class: "kv leaves" });
|
|
616
|
+
for (const leaf of leaves) {
|
|
617
|
+
const row = el("tr");
|
|
618
|
+
row.append(el("td", { class: "k", text: leaf.path }));
|
|
619
|
+
const cell = el("td");
|
|
620
|
+
cell.append(valueNode(leaf.prev), el("span", { class: "arrow", text: "\u2192" }), valueNode(leaf.next));
|
|
621
|
+
row.append(cell);
|
|
622
|
+
table.append(row);
|
|
623
|
+
}
|
|
624
|
+
d.append(table);
|
|
625
|
+
td.append(d);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
tr.append(td);
|
|
629
|
+
return tr;
|
|
630
|
+
}
|
|
631
|
+
function kvSection(title, next, changes) {
|
|
632
|
+
const byKey = new Map(changes.map((c) => [c.path.split(/[.[]/)[0], c]));
|
|
633
|
+
const table = el("table", { class: "kv" });
|
|
634
|
+
for (const k of Object.keys(next || {})) {
|
|
635
|
+
const c = byKey.get(k);
|
|
636
|
+
if (c) {
|
|
637
|
+
table.append(changeRow(k, c, c.path !== k ? ` at ${c.path}` : ""));
|
|
638
|
+
} else {
|
|
639
|
+
const tr = el("tr");
|
|
640
|
+
tr.append(el("td", { class: "k", text: k }));
|
|
641
|
+
const td = el("td");
|
|
642
|
+
td.append(valueNode(next[k]));
|
|
643
|
+
tr.append(td);
|
|
644
|
+
table.append(tr);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for (const c of changes) if (c.kind === "removed") table.append(changeRow(c.path, c));
|
|
648
|
+
if (!table.children.length) table.append(el("tr", null, [el("td", { class: "v nil", text: "no props" })]));
|
|
649
|
+
return el("div", { class: "section" }, [el("h3", { text: title }), table]);
|
|
650
|
+
}
|
|
651
|
+
function sourceLabel(src) {
|
|
652
|
+
const file = src.fileName.replace(/^https?:\/\/[^/]+/, "").replace(/\?.*$/, "");
|
|
653
|
+
return `${file}${src.lineNumber ? ":" + src.lineNumber : ""}`;
|
|
654
|
+
}
|
|
655
|
+
function reportView(r, actions = {}) {
|
|
656
|
+
const frag = document.createDocumentFragment();
|
|
657
|
+
const duration = typeof r.selfDuration === "number" ? ` \xB7 ${fmtMs(r.selfDuration)} self${typeof r.treeDuration === "number" && r.treeDuration > r.selfDuration ? `, ${fmtMs(r.treeDuration)} with children` : ""}` : "";
|
|
658
|
+
const head = el("div", { class: "section" }, [
|
|
659
|
+
el("h3", { text: "Why did this render?" }),
|
|
660
|
+
el("div", null, [
|
|
661
|
+
el("span", { class: "verdict " + (r.avoidable ? "avoid" : "ok"), text: r.avoidable ? "Avoidable re-render" : `Re-render (${r.trigger})` }),
|
|
662
|
+
el("span", { class: "meta", text: ` #${r.renderCount} \xB7 ${summarize(r)}${duration}` })
|
|
663
|
+
]),
|
|
664
|
+
el("ul", { class: "reasons" }, (r.reasons || []).map((x) => el("li", { text: x })))
|
|
665
|
+
]);
|
|
666
|
+
if (!actions.compact) {
|
|
667
|
+
const bar = el("div", { class: "actions" });
|
|
668
|
+
const src = r.source;
|
|
669
|
+
if (src && actions.openSource) {
|
|
670
|
+
const open = actions.openSource;
|
|
671
|
+
bar.append(el("button", { title: src.fileName, onclick: () => open(src) }, `\u2197 ${sourceLabel(src)}`));
|
|
672
|
+
} else if (src) bar.append(el("span", { class: "meta", title: src.fileName, text: sourceLabel(src) }));
|
|
673
|
+
if (actions.highlight && r.instanceId) {
|
|
674
|
+
const highlight = actions.highlight;
|
|
675
|
+
bar.append(el("button", { onclick: () => highlight(r.instanceId) }, "\u25A3 Highlight"));
|
|
676
|
+
}
|
|
677
|
+
if (actions.copy) {
|
|
678
|
+
const copy = actions.copy;
|
|
679
|
+
bar.append(el("button", { onclick: () => copy(reportToMarkdown(r)) }, "\u2398 Copy as Markdown"));
|
|
680
|
+
}
|
|
681
|
+
if (bar.children.length) head.append(bar);
|
|
682
|
+
const loc = r.source;
|
|
683
|
+
if (loc && loc.lineNumber && actions.readSource) {
|
|
684
|
+
const box = el("pre", { class: "source-context", text: "loading source\u2026" });
|
|
685
|
+
head.append(box);
|
|
686
|
+
const line = loc.lineNumber;
|
|
687
|
+
void actions.readSource(loc.fileName).then((text) => {
|
|
688
|
+
box.textContent = "";
|
|
689
|
+
if (!text) {
|
|
690
|
+
box.textContent = "source not available";
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
for (const l of sourceContext(text, line)) box.append(el("span", { class: "line" + (l.hit ? " hit" : "") }, [el("span", { class: "ln", text: String(l.n).padStart(4) }), " ", l.text, "\n"]));
|
|
694
|
+
}).catch(() => {
|
|
695
|
+
box.textContent = "source not available";
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
frag.append(head);
|
|
700
|
+
const by = el("div", { class: "section" }, [el("h3", { text: "Rendered by" })]);
|
|
701
|
+
const crumbs = el("div", { class: "crumbs" });
|
|
702
|
+
const parts = [].concat(r.path || []);
|
|
703
|
+
parts.forEach((p, i) => {
|
|
704
|
+
if (i) crumbs.append(" \u203A ");
|
|
705
|
+
crumbs.append(p);
|
|
706
|
+
});
|
|
707
|
+
if (parts.length) crumbs.append(" \u203A ");
|
|
708
|
+
crumbs.append(el("b", { text: r.component }));
|
|
709
|
+
by.append(crumbs);
|
|
710
|
+
if (r.parent) by.append(el("div", { text: `Triggered by <${r.parent.name}> (${r.parent.trigger})` }));
|
|
711
|
+
else by.append(el("div", { text: "Update started in this component" }));
|
|
712
|
+
if (r.owner) by.append(el("div", { class: "meta", text: `Created by <${r.owner}>` }));
|
|
713
|
+
if (r.memoized === false) by.append(el("div", { class: "meta", text: "Not memoized (re-renders whenever its parent does)" }));
|
|
714
|
+
else if (r.memoized === true) by.append(el("div", { class: "meta", text: "Memoized (React.memo / PureComponent)" }));
|
|
715
|
+
if (r.updaters && r.updaters.length) by.append(el("div", { text: `Update scheduled by ${r.updaters.map((u) => `<${u}>`).join(", ")}` }));
|
|
716
|
+
if (r.commitCause === "effect-after-commit") by.append(el("div", { class: "cause effect", text: `Effect loop: state set right after commit #${r.afterCommit ?? "?"}` }));
|
|
717
|
+
else if (r.commitCause === "suspense-resolved") by.append(el("div", { class: "cause suspense", text: "Suspense boundary resolved in this commit" }));
|
|
718
|
+
if (r.commitId) by.append(el("div", { class: "meta", text: `Commit #${r.commitId}${r.commitPriority ? ` \xB7 ${PRIORITY_LABEL[r.commitPriority] || r.commitPriority} priority` : ""}` }));
|
|
719
|
+
frag.append(by);
|
|
720
|
+
frag.append(kvSection("Props", r.props ? r.props.next : {}, r.propChanges || []));
|
|
721
|
+
const hookChanges = new Map((r.hookChanges || []).map((c) => [c.path, c]));
|
|
722
|
+
const stateChanges = r.stateChanges || [];
|
|
723
|
+
if (r.hookState || r.contexts || r.state) {
|
|
724
|
+
if (r.hookState && r.hookState.length) {
|
|
725
|
+
const table = el("table", { class: "kv" });
|
|
726
|
+
for (const h of r.hookState) {
|
|
727
|
+
const c = hookChanges.get(h.path);
|
|
728
|
+
const label = h.custom && h.custom.length ? `${h.custom.join(" \u203A ")} \u203A ${h.path}` : h.path;
|
|
729
|
+
if (c) table.append(changeRow(label, c));
|
|
730
|
+
else {
|
|
731
|
+
const tr = el("tr");
|
|
732
|
+
tr.append(el("td", { class: "k", text: label }));
|
|
733
|
+
const td = el("td");
|
|
734
|
+
td.append(valueNode(h.value));
|
|
735
|
+
tr.append(td);
|
|
736
|
+
table.append(tr);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
frag.append(el("div", { class: "section" }, [el("h3", { text: "Hooks" }), table]));
|
|
740
|
+
}
|
|
741
|
+
if (r.state) frag.append(kvSection("State", r.state, stateChanges));
|
|
742
|
+
if (r.contexts && r.contexts.length) {
|
|
743
|
+
const table = el("table", { class: "kv" });
|
|
744
|
+
for (const ctx of r.contexts) {
|
|
745
|
+
const c = hookChanges.get(`useContext(${ctx.name})`);
|
|
746
|
+
if (c) table.append(changeRow(ctx.name, c));
|
|
747
|
+
else {
|
|
748
|
+
const tr = el("tr");
|
|
749
|
+
tr.append(el("td", { class: "k", text: ctx.name }));
|
|
750
|
+
const td = el("td");
|
|
751
|
+
td.append(valueNode(ctx.value));
|
|
752
|
+
tr.append(td);
|
|
753
|
+
table.append(tr);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
frag.append(el("div", { class: "section" }, [el("h3", { text: "Contexts" }), table]));
|
|
757
|
+
}
|
|
758
|
+
const leftover = [...hookChanges.values()].filter((c) => !(r.hookState || []).some((h) => h.path === c.path) && !(r.contexts || []).some((x) => `useContext(${x.name})` === c.path));
|
|
759
|
+
if (leftover.length) {
|
|
760
|
+
const table = el("table", { class: "kv" });
|
|
761
|
+
for (const c of leftover) table.append(changeRow(c.path, c));
|
|
762
|
+
frag.append(el("div", { class: "section" }, [el("h3", { text: "Other hooks that changed" }), table]));
|
|
763
|
+
}
|
|
764
|
+
return frag;
|
|
765
|
+
}
|
|
766
|
+
const hooks = [].concat(r.hookChanges || [], stateChanges);
|
|
767
|
+
if (hooks.length) {
|
|
768
|
+
const table = el("table", { class: "kv" });
|
|
769
|
+
for (const c of hooks) table.append(changeRow(c.custom && c.custom.length ? `${c.custom.join(" \u203A ")} \u203A ${c.path}` : c.path, c));
|
|
770
|
+
frag.append(el("div", { class: "section" }, [el("h3", { text: "State & hooks that changed" }), table]));
|
|
771
|
+
}
|
|
772
|
+
return frag;
|
|
773
|
+
}
|
|
774
|
+
function fixView(fixes, actions = {}) {
|
|
775
|
+
const frag = document.createDocumentFragment();
|
|
776
|
+
if (!fixes.length) {
|
|
777
|
+
frag.append(el("div", { class: "section" }, [el("h3", { text: "Fix" }), el("div", { class: "meta", text: "Nothing to fix: this render was caused by a genuine change." })]));
|
|
778
|
+
return frag;
|
|
779
|
+
}
|
|
780
|
+
for (const f of fixes) {
|
|
781
|
+
const ranked = "count" in f ? f : null;
|
|
782
|
+
const sec = el("div", { class: "section fix" }, [
|
|
783
|
+
el("h3", { text: f.label }),
|
|
784
|
+
el("div", { text: f.detail }),
|
|
785
|
+
ranked ? el("div", { class: "meta", text: `removes ${plural(ranked.count, "avoidable re-render")}: ${componentList(ranked.components)}` }) : null,
|
|
786
|
+
el("pre", { class: "snippet", text: f.snippet })
|
|
787
|
+
]);
|
|
788
|
+
if (actions.copy) {
|
|
789
|
+
const copy = actions.copy;
|
|
790
|
+
sec.append(el("button", { onclick: () => copy(f.snippet) }, "\u2398 Copy snippet"));
|
|
791
|
+
}
|
|
792
|
+
frag.append(sec);
|
|
793
|
+
}
|
|
794
|
+
return frag;
|
|
795
|
+
}
|
|
796
|
+
function virtualList(container, rowHeight, rowFor, opts = {}) {
|
|
797
|
+
const inner = el("div", { class: "virtual-inner", role: "list" });
|
|
798
|
+
if (opts.attach !== false) container.append(inner);
|
|
799
|
+
let items = [];
|
|
800
|
+
const mounted = /* @__PURE__ */ new Map();
|
|
801
|
+
let raf = 0;
|
|
802
|
+
const render = () => {
|
|
803
|
+
raf = 0;
|
|
804
|
+
if (!inner.isConnected) return;
|
|
805
|
+
const height = container.clientHeight || FALLBACK_VIEWPORT;
|
|
806
|
+
const top = container.scrollTop - (opts.headerHeight ? opts.headerHeight() : 0);
|
|
807
|
+
const start = Math.max(0, Math.floor(top / rowHeight) - OVERSCAN);
|
|
808
|
+
const end = Math.min(items.length, Math.ceil((top + height) / rowHeight) + OVERSCAN);
|
|
809
|
+
inner.style.height = `${items.length * rowHeight}px`;
|
|
810
|
+
const keep = /* @__PURE__ */ new Set();
|
|
811
|
+
for (let i = start; i < end; i++) {
|
|
812
|
+
const row = rowFor(items[i], i);
|
|
813
|
+
row.style.top = `${i * rowHeight}px`;
|
|
814
|
+
if (row.parentNode !== inner) inner.append(row);
|
|
815
|
+
mounted.set(row, i);
|
|
816
|
+
keep.add(row);
|
|
817
|
+
}
|
|
818
|
+
for (const row of [...mounted.keys()]) {
|
|
819
|
+
if (!keep.has(row)) {
|
|
820
|
+
row.remove();
|
|
821
|
+
mounted.delete(row);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
container.addEventListener("scroll", () => {
|
|
826
|
+
if (!raf) raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame(render) : setTimeout(render, 0);
|
|
827
|
+
});
|
|
828
|
+
return {
|
|
829
|
+
container,
|
|
830
|
+
inner,
|
|
831
|
+
get items() {
|
|
832
|
+
return items;
|
|
833
|
+
},
|
|
834
|
+
setItems(next) {
|
|
835
|
+
items = next;
|
|
836
|
+
render();
|
|
837
|
+
},
|
|
838
|
+
render,
|
|
839
|
+
scrollTo(index) {
|
|
840
|
+
const height = container.clientHeight || FALLBACK_VIEWPORT;
|
|
841
|
+
const top = index * rowHeight;
|
|
842
|
+
if (top < container.scrollTop) container.scrollTop = top;
|
|
843
|
+
else if (top + rowHeight > container.scrollTop + height) container.scrollTop = top + rowHeight - height;
|
|
844
|
+
render();
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function createPanel(root, transport, options = {}) {
|
|
849
|
+
const state = {
|
|
850
|
+
tree: { name: "", children: /* @__PURE__ */ new Map(), reports: [], total: 0, avoidable: 0, wasted: 0, expanded: true, path: [], key: "" },
|
|
851
|
+
nodesByKey: /* @__PURE__ */ new Map(),
|
|
852
|
+
reports: [],
|
|
853
|
+
commits: /* @__PURE__ */ new Map(),
|
|
854
|
+
commitOrder: [],
|
|
855
|
+
selectedKey: null,
|
|
856
|
+
selectedReport: null,
|
|
857
|
+
selectedCommit: null,
|
|
858
|
+
selectedFix: null,
|
|
859
|
+
selectedRoot: null,
|
|
860
|
+
view: "tree",
|
|
861
|
+
tab: "latest",
|
|
862
|
+
paused: false,
|
|
863
|
+
avoidableOnly: false,
|
|
864
|
+
filter: "",
|
|
865
|
+
relay: false,
|
|
866
|
+
library: null,
|
|
867
|
+
polling: false,
|
|
868
|
+
streamCollapsed: false,
|
|
869
|
+
collapsed: /* @__PURE__ */ new Set(),
|
|
870
|
+
sort: { key: "avoidable", dir: -1 },
|
|
871
|
+
flashOn: false,
|
|
872
|
+
settingsOpen: false,
|
|
873
|
+
origin: null,
|
|
874
|
+
tabLabel: transport.tabLabel ?? null,
|
|
875
|
+
compact: false,
|
|
876
|
+
sessions: [],
|
|
877
|
+
recording: null,
|
|
878
|
+
selectedSession: null,
|
|
879
|
+
compareWith: null,
|
|
880
|
+
byInstance: false
|
|
881
|
+
};
|
|
882
|
+
let persistTimer = null;
|
|
883
|
+
let queue = [];
|
|
884
|
+
let flushScheduled = false;
|
|
885
|
+
const schedule = typeof requestAnimationFrame === "function" ? (fn) => requestAnimationFrame(fn) : (fn) => setTimeout(fn, 0);
|
|
886
|
+
const copyText = (text) => {
|
|
887
|
+
if (transport.copy) transport.copy(text);
|
|
888
|
+
else if (typeof navigator !== "undefined" && navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {
|
|
889
|
+
});
|
|
890
|
+
toast("Copied");
|
|
891
|
+
};
|
|
892
|
+
root.textContent = "";
|
|
893
|
+
const search = el("input", {
|
|
894
|
+
type: "search",
|
|
895
|
+
placeholder: "Search components (text or /regex/)",
|
|
896
|
+
oninput: () => {
|
|
897
|
+
state.filter = search.value;
|
|
898
|
+
renderLeft();
|
|
899
|
+
renderStream();
|
|
900
|
+
persist();
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
const iconButton = (glyph, label, title, onclick, extra = {}) => el("button", { class: "ib", title, onclick, "aria-label": label, ...extra }, [el("span", { class: "glyph", text: glyph }), el("span", { class: "label", text: label })]);
|
|
904
|
+
const pauseBtn = iconButton("\u23F8", "Pause", "Pause / resume (reports keep buffering in the page)", () => {
|
|
905
|
+
state.paused = !state.paused;
|
|
906
|
+
pauseBtn.classList.toggle("active", state.paused);
|
|
907
|
+
pauseBtn.querySelector(".glyph").textContent = state.paused ? "\u25B6" : "\u23F8";
|
|
908
|
+
pauseBtn.querySelector(".label").textContent = state.paused ? "Resume" : "Pause";
|
|
909
|
+
});
|
|
910
|
+
const clearBtn = iconButton("\u2298", "Clear", "Clear the panel and the page buffer", () => {
|
|
911
|
+
clearAll();
|
|
912
|
+
transport.clear?.();
|
|
913
|
+
transport.badge?.(0);
|
|
914
|
+
});
|
|
915
|
+
const replayBtn = iconButton("\u21BB", "Replay", "Replay buffered reports from the page", () => transport.replay?.());
|
|
916
|
+
const recordBtn = iconButton("\u23FA", "Record", "Record a session to compare before and after a fix", () => {
|
|
917
|
+
if (state.recording) stopRecording();
|
|
918
|
+
else startRecording();
|
|
919
|
+
});
|
|
920
|
+
const exportBtn = iconButton("\u2913", "Export", "Export reports as JSON", exportJson);
|
|
921
|
+
const importInput = el("input", { type: "file", accept: "application/json,.json", class: "hidden-file" });
|
|
922
|
+
importInput.addEventListener("change", () => {
|
|
923
|
+
const f = importInput.files && importInput.files[0];
|
|
924
|
+
if (f) importFile(f);
|
|
925
|
+
importInput.value = "";
|
|
926
|
+
});
|
|
927
|
+
const importBtn = iconButton("\u2912", "Import", "Import a JSON export", () => importInput.click());
|
|
928
|
+
const avoidCheck = el("input", {
|
|
929
|
+
type: "checkbox",
|
|
930
|
+
onchange: () => {
|
|
931
|
+
state.avoidableOnly = avoidCheck.checked;
|
|
932
|
+
renderLeft();
|
|
933
|
+
renderStream();
|
|
934
|
+
persist();
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
const settingsBtn = iconButton("\u2699", "Settings", "Settings", () => toggleSettings());
|
|
938
|
+
const status = el("span", { class: "status", title: "" }, [el("span", { class: "dot" }), el("span", { class: "status-text", text: "no page" })]);
|
|
939
|
+
const tabChip = el("span", { class: "tab-chip", hidden: true, title: "The tab this panel follows" });
|
|
940
|
+
const undock = transport.undock ? [
|
|
941
|
+
el("span", { class: "sep" }),
|
|
942
|
+
iconButton("\u2AFF", "Side panel", "Show this panel next to the page (Chrome side panel)", () => void transport.undock("sidepanel").catch((e) => toast(String(e.message || e)))),
|
|
943
|
+
iconButton("\u29C9", "Window", "Show this panel in its own window", () => void transport.undock("window").catch((e) => toast(String(e.message || e))))
|
|
944
|
+
] : [];
|
|
945
|
+
const toolbar = el("div", { class: "toolbar" }, [
|
|
946
|
+
search,
|
|
947
|
+
el("span", { class: "sep" }),
|
|
948
|
+
pauseBtn,
|
|
949
|
+
clearBtn,
|
|
950
|
+
replayBtn,
|
|
951
|
+
recordBtn,
|
|
952
|
+
el("span", { class: "sep" }),
|
|
953
|
+
exportBtn,
|
|
954
|
+
importBtn,
|
|
955
|
+
importInput,
|
|
956
|
+
el("span", { class: "sep" }),
|
|
957
|
+
el("label", { class: "check" }, [avoidCheck, el("span", { class: "label", text: "Avoidable only" })]),
|
|
958
|
+
...undock,
|
|
959
|
+
el("span", { class: "spacer" }),
|
|
960
|
+
tabChip,
|
|
961
|
+
status,
|
|
962
|
+
settingsBtn
|
|
963
|
+
]);
|
|
964
|
+
const summary = el("div", { class: "summary" });
|
|
965
|
+
const banner = el("div", { class: "banner", hidden: true });
|
|
966
|
+
const viewsBar = el("div", { class: "views" });
|
|
967
|
+
const VIEWS = [
|
|
968
|
+
["tree", "Tree"],
|
|
969
|
+
["offenders", "Offenders"],
|
|
970
|
+
["commits", "Commits"],
|
|
971
|
+
["fixes", "Fixes"],
|
|
972
|
+
["sessions", "Sessions"]
|
|
973
|
+
];
|
|
974
|
+
const viewButtons = /* @__PURE__ */ new Map();
|
|
975
|
+
for (const [id, label] of VIEWS) {
|
|
976
|
+
const b = el("button", { "data-view": id, onclick: () => setView(id) }, label);
|
|
977
|
+
viewButtons.set(id, b);
|
|
978
|
+
viewsBar.append(b);
|
|
979
|
+
}
|
|
980
|
+
const instancesBtn = el(
|
|
981
|
+
"button",
|
|
982
|
+
{
|
|
983
|
+
class: "instances",
|
|
984
|
+
title: "Group the tree by instance (key or id) instead of by component name",
|
|
985
|
+
onclick: () => {
|
|
986
|
+
state.byInstance = !state.byInstance;
|
|
987
|
+
instancesBtn.classList.toggle("active", state.byInstance);
|
|
988
|
+
rebuildTree();
|
|
989
|
+
persist();
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
"\u205D Instances"
|
|
993
|
+
);
|
|
994
|
+
viewsBar.append(el("span", { class: "spacer" }), instancesBtn);
|
|
995
|
+
const tree = el("div", { class: "tree", tabindex: "0", onkeydown: onTreeKey, role: "tree" });
|
|
996
|
+
const table = el("div", { class: "table-wrap", hidden: true });
|
|
997
|
+
const left = el("div", { class: "left" }, [viewsBar, tree, table]);
|
|
998
|
+
const resizer = el("div", { class: "resizer", title: "Drag to resize" });
|
|
999
|
+
const details = el("div", { class: "details" });
|
|
1000
|
+
const settings = el("div", { class: "drawer", hidden: true });
|
|
1001
|
+
const main = el("div", { class: "main" }, [left, resizer, details, settings]);
|
|
1002
|
+
const streamList = el("div", { class: "stream-list" });
|
|
1003
|
+
const streamCount = el("span", { class: "count", text: "0 reports" });
|
|
1004
|
+
const stream = el("div", { class: "stream" }, [
|
|
1005
|
+
el(
|
|
1006
|
+
"div",
|
|
1007
|
+
{
|
|
1008
|
+
class: "stream-header",
|
|
1009
|
+
onclick: () => {
|
|
1010
|
+
state.streamCollapsed = !state.streamCollapsed;
|
|
1011
|
+
stream.classList.toggle("collapsed", state.streamCollapsed);
|
|
1012
|
+
persist();
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
[el("span", { text: "\u25BE Live stream" }), streamCount]
|
|
1016
|
+
),
|
|
1017
|
+
streamList
|
|
1018
|
+
]);
|
|
1019
|
+
const toastEl = el("div", { class: "toast", hidden: true, role: "status", "aria-live": "polite" });
|
|
1020
|
+
root.classList.add("rl");
|
|
1021
|
+
search.setAttribute("aria-label", "Search components; prefix with ~ to search values");
|
|
1022
|
+
search.placeholder = "Search components (text, /regex/, ~value)";
|
|
1023
|
+
streamList.setAttribute("aria-label", "Live stream of reports");
|
|
1024
|
+
details.setAttribute("role", "region");
|
|
1025
|
+
details.setAttribute("aria-label", "Details");
|
|
1026
|
+
settings.setAttribute("role", "dialog");
|
|
1027
|
+
settings.setAttribute("aria-label", "Settings");
|
|
1028
|
+
root.append(toolbar, summary, banner, main, stream, toastEl);
|
|
1029
|
+
root.addEventListener("keydown", onGlobalKey);
|
|
1030
|
+
function setCompact(on) {
|
|
1031
|
+
if (state.compact === on) return;
|
|
1032
|
+
state.compact = on;
|
|
1033
|
+
root.classList.toggle("compact", on);
|
|
1034
|
+
treeList.render();
|
|
1035
|
+
streamItems.render();
|
|
1036
|
+
}
|
|
1037
|
+
const measure = () => setCompact(root.clientWidth > 0 && root.clientWidth < 720);
|
|
1038
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(measure).observe(root);
|
|
1039
|
+
else window.addEventListener("resize", measure);
|
|
1040
|
+
const totals = { avoidable: 0, wasted: 0, perComponent: /* @__PURE__ */ new Map() };
|
|
1041
|
+
let bestFix;
|
|
1042
|
+
let bestFixAt = 0;
|
|
1043
|
+
let bestFixGen = -1;
|
|
1044
|
+
let bestFixTimer = null;
|
|
1045
|
+
const throttled = () => state.reports.length >= THROTTLE_MIN_REPORTS;
|
|
1046
|
+
function refreshBestFix() {
|
|
1047
|
+
if (!totals.avoidable) {
|
|
1048
|
+
bestFix = void 0;
|
|
1049
|
+
bestFixGen = dataGen;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (bestFixGen === dataGen) return;
|
|
1053
|
+
const now = Date.now();
|
|
1054
|
+
if (throttled() && now - bestFixAt < BEST_FIX_INTERVAL) {
|
|
1055
|
+
if (!bestFixTimer) {
|
|
1056
|
+
bestFixTimer = setTimeout(() => {
|
|
1057
|
+
bestFixTimer = null;
|
|
1058
|
+
renderSummary();
|
|
1059
|
+
}, BEST_FIX_INTERVAL - (now - bestFixAt));
|
|
1060
|
+
}
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
bestFixAt = now;
|
|
1064
|
+
bestFixGen = dataGen;
|
|
1065
|
+
bestFix = rankFixes(state.reports)[0];
|
|
1066
|
+
}
|
|
1067
|
+
function renderSummary() {
|
|
1068
|
+
summary.textContent = "";
|
|
1069
|
+
const total = state.reports.length;
|
|
1070
|
+
if (!total) {
|
|
1071
|
+
summary.hidden = true;
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
summary.hidden = false;
|
|
1075
|
+
const { avoidable, wasted } = totals;
|
|
1076
|
+
let top;
|
|
1077
|
+
for (const entry of totals.perComponent) if (!top || entry[1] > top[1]) top = entry;
|
|
1078
|
+
refreshBestFix();
|
|
1079
|
+
const fix = bestFix;
|
|
1080
|
+
const stat = (value, label, cls = "") => el("span", { class: "stat " + cls }, [el("b", { text: value }), el("span", { class: "label", text: label })]);
|
|
1081
|
+
summary.append(stat(String(total), plural(total, "render").replace(/^\d+ /, "")), stat(String(avoidable), "avoidable", avoidable ? "bad" : "good"));
|
|
1082
|
+
if (wasted) summary.append(stat(fmtMs(wasted), "wasted", "bad"));
|
|
1083
|
+
if (top) {
|
|
1084
|
+
summary.append(
|
|
1085
|
+
el("button", { class: "stat link", title: "Select the component with the most avoidable re-renders", onclick: () => panelApi.select(top[0]) }, [
|
|
1086
|
+
el("span", { class: "label", text: "top" }),
|
|
1087
|
+
el("b", { class: "mono", text: `<${top[0]}>` }),
|
|
1088
|
+
el("span", { class: "label", text: `\xD7${top[1]}` })
|
|
1089
|
+
])
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (fix) {
|
|
1093
|
+
summary.append(
|
|
1094
|
+
el(
|
|
1095
|
+
"button",
|
|
1096
|
+
{
|
|
1097
|
+
class: "stat link",
|
|
1098
|
+
title: "Open the Fixes view",
|
|
1099
|
+
onclick: () => {
|
|
1100
|
+
state.selectedFix = fix.key;
|
|
1101
|
+
state.tab = "fixlist";
|
|
1102
|
+
setView("fixes");
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
[el("span", { class: "label", text: "best fix" }), el("b", { class: "mono", text: fix.label }), el("span", { class: "label", text: `\u2212${fix.count}` })]
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
let toastTimer = null;
|
|
1111
|
+
function toast(text) {
|
|
1112
|
+
toastEl.textContent = text;
|
|
1113
|
+
toastEl.hidden = false;
|
|
1114
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
1115
|
+
toastTimer = setTimeout(() => {
|
|
1116
|
+
toastEl.hidden = true;
|
|
1117
|
+
}, 1200);
|
|
1118
|
+
}
|
|
1119
|
+
let drag = null;
|
|
1120
|
+
resizer.addEventListener("mousedown", (e) => {
|
|
1121
|
+
drag = { x: e.clientX, w: left.getBoundingClientRect().width };
|
|
1122
|
+
e.preventDefault();
|
|
1123
|
+
});
|
|
1124
|
+
window.addEventListener("mousemove", (e) => {
|
|
1125
|
+
if (!drag) return;
|
|
1126
|
+
const w = Math.max(180, Math.min(drag.w + e.clientX - drag.x, root.clientWidth - 240));
|
|
1127
|
+
left.style.width = w + "px";
|
|
1128
|
+
state.treeWidth = w;
|
|
1129
|
+
});
|
|
1130
|
+
window.addEventListener("mouseup", () => {
|
|
1131
|
+
if (drag) persist();
|
|
1132
|
+
drag = null;
|
|
1133
|
+
});
|
|
1134
|
+
function persist() {
|
|
1135
|
+
if (!transport.storage) return;
|
|
1136
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
1137
|
+
persistTimer = setTimeout(() => {
|
|
1138
|
+
const saved = {
|
|
1139
|
+
filter: state.filter,
|
|
1140
|
+
avoidableOnly: state.avoidableOnly,
|
|
1141
|
+
view: state.view,
|
|
1142
|
+
tab: state.tab === "history" || state.tab === "fix" ? state.tab : "latest",
|
|
1143
|
+
streamCollapsed: state.streamCollapsed,
|
|
1144
|
+
collapsed: [...state.collapsed],
|
|
1145
|
+
treeWidth: state.treeWidth,
|
|
1146
|
+
flashOn: state.flashOn,
|
|
1147
|
+
byInstance: state.byInstance
|
|
1148
|
+
};
|
|
1149
|
+
transport.storage.set("panel", saved);
|
|
1150
|
+
}, 150);
|
|
1151
|
+
}
|
|
1152
|
+
function restoreSessions(raw) {
|
|
1153
|
+
if (!Array.isArray(raw)) return;
|
|
1154
|
+
for (const s of raw) {
|
|
1155
|
+
if (!isRecord(s) || typeof s.id !== "string" || typeof s.name !== "string" || !isRecord(s.byComponent)) continue;
|
|
1156
|
+
if (state.sessions.some((x) => x.id === s.id)) continue;
|
|
1157
|
+
state.sessions.push({
|
|
1158
|
+
id: s.id,
|
|
1159
|
+
name: s.name,
|
|
1160
|
+
startedAt: typeof s.startedAt === "number" ? s.startedAt : 0,
|
|
1161
|
+
endedAt: typeof s.endedAt === "number" ? s.endedAt : 0,
|
|
1162
|
+
total: typeof s.total === "number" ? s.total : 0,
|
|
1163
|
+
avoidable: typeof s.avoidable === "number" ? s.avoidable : 0,
|
|
1164
|
+
wasted: typeof s.wasted === "number" ? s.wasted : 0,
|
|
1165
|
+
byComponent: s.byComponent,
|
|
1166
|
+
fixes: Array.isArray(s.fixes) ? s.fixes : [],
|
|
1167
|
+
reports: []
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
state.sessions.sort((a, b) => a.startedAt - b.startedAt);
|
|
1171
|
+
if (state.view === "sessions") renderLeft();
|
|
1172
|
+
}
|
|
1173
|
+
function restore(raw) {
|
|
1174
|
+
if (!isRecord(raw)) return;
|
|
1175
|
+
const saved = raw;
|
|
1176
|
+
if (typeof saved.filter === "string") {
|
|
1177
|
+
state.filter = saved.filter;
|
|
1178
|
+
search.value = saved.filter;
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof saved.avoidableOnly === "boolean") {
|
|
1181
|
+
state.avoidableOnly = saved.avoidableOnly;
|
|
1182
|
+
avoidCheck.checked = saved.avoidableOnly;
|
|
1183
|
+
}
|
|
1184
|
+
if (Array.isArray(saved.collapsed)) state.collapsed = new Set(saved.collapsed.filter((x) => typeof x === "string"));
|
|
1185
|
+
if (typeof saved.streamCollapsed === "boolean") {
|
|
1186
|
+
state.streamCollapsed = saved.streamCollapsed;
|
|
1187
|
+
stream.classList.toggle("collapsed", state.streamCollapsed);
|
|
1188
|
+
}
|
|
1189
|
+
if (typeof saved.treeWidth === "number" && saved.treeWidth > 100) {
|
|
1190
|
+
state.treeWidth = saved.treeWidth;
|
|
1191
|
+
left.style.width = saved.treeWidth + "px";
|
|
1192
|
+
}
|
|
1193
|
+
if (typeof saved.flashOn === "boolean") state.flashOn = saved.flashOn;
|
|
1194
|
+
if (typeof saved.byInstance === "boolean" && saved.byInstance !== state.byInstance) {
|
|
1195
|
+
state.byInstance = saved.byInstance;
|
|
1196
|
+
instancesBtn.classList.toggle("active", state.byInstance);
|
|
1197
|
+
rebuildTree();
|
|
1198
|
+
}
|
|
1199
|
+
if (saved.tab === "history" || saved.tab === "fix") state.tab = saved.tab;
|
|
1200
|
+
if (saved.view && viewButtons.has(saved.view)) state.view = saved.view;
|
|
1201
|
+
for (const n of state.nodesByKey.values()) n.expanded = !state.collapsed.has(n.key);
|
|
1202
|
+
setView(state.view);
|
|
1203
|
+
renderStream();
|
|
1204
|
+
}
|
|
1205
|
+
const keyOf = (path) => path.join(" ");
|
|
1206
|
+
function nodeFor(path) {
|
|
1207
|
+
const key = keyOf(path);
|
|
1208
|
+
const found = state.nodesByKey.get(key);
|
|
1209
|
+
if (found) return found;
|
|
1210
|
+
let parent = state.tree;
|
|
1211
|
+
for (let i = 0; i < path.length; i++) {
|
|
1212
|
+
const name = path[i];
|
|
1213
|
+
const k = keyOf(path.slice(0, i + 1));
|
|
1214
|
+
let n = state.nodesByKey.get(k);
|
|
1215
|
+
if (!n) {
|
|
1216
|
+
n = { name, children: /* @__PURE__ */ new Map(), reports: [], total: 0, avoidable: 0, wasted: 0, expanded: !state.collapsed.has(k), path: path.slice(0, i + 1), key: k };
|
|
1217
|
+
state.nodesByKey.set(k, n);
|
|
1218
|
+
parent.children.set(name, n);
|
|
1219
|
+
}
|
|
1220
|
+
parent = n;
|
|
1221
|
+
}
|
|
1222
|
+
return parent;
|
|
1223
|
+
}
|
|
1224
|
+
const instanceLabel = (r) => r.key ? `${r.component} key=${JSON.stringify(r.key)}` : `${r.component} #${r.instanceId}`;
|
|
1225
|
+
const nodeOfReport = (r) => nodeFor(r.path.concat([state.byInstance ? instanceLabel(r) : r.component]));
|
|
1226
|
+
function rebuildTree() {
|
|
1227
|
+
state.tree.children.clear();
|
|
1228
|
+
state.nodesByKey.clear();
|
|
1229
|
+
rowEls.clear();
|
|
1230
|
+
state.selectedKey = null;
|
|
1231
|
+
for (const r of state.reports) {
|
|
1232
|
+
const node = nodeOfReport(r);
|
|
1233
|
+
node.reports.push(r);
|
|
1234
|
+
node.total++;
|
|
1235
|
+
if (r.avoidable) {
|
|
1236
|
+
node.avoidable++;
|
|
1237
|
+
if (typeof r.selfDuration === "number") node.wasted += r.selfDuration;
|
|
1238
|
+
}
|
|
1239
|
+
node.lastReport = r;
|
|
1240
|
+
}
|
|
1241
|
+
for (const node of state.nodesByKey.values()) trimNode(node);
|
|
1242
|
+
renderLeft();
|
|
1243
|
+
renderDetails();
|
|
1244
|
+
}
|
|
1245
|
+
function trimNode(node) {
|
|
1246
|
+
const excess = node.reports.length - MAX_PER_NODE;
|
|
1247
|
+
if (excess > 0) node.reports.splice(0, excess);
|
|
1248
|
+
}
|
|
1249
|
+
const commitAnalyses = /* @__PURE__ */ new Map();
|
|
1250
|
+
function analysisFor(key, reports) {
|
|
1251
|
+
const cached = commitAnalyses.get(key);
|
|
1252
|
+
if (cached && cached.len === reports.length) return cached.analysis;
|
|
1253
|
+
const analysis = analyzeCommit(reports);
|
|
1254
|
+
commitAnalyses.set(key, { len: reports.length, analysis });
|
|
1255
|
+
return analysis;
|
|
1256
|
+
}
|
|
1257
|
+
const commitMember = /* @__PURE__ */ new WeakSet();
|
|
1258
|
+
function forgetCommit(key) {
|
|
1259
|
+
state.commits.delete(key);
|
|
1260
|
+
commitAnalyses.delete(key);
|
|
1261
|
+
}
|
|
1262
|
+
function ingest(report) {
|
|
1263
|
+
if (!report.receivedAt) report.receivedAt = Date.now();
|
|
1264
|
+
state.reports.push(report);
|
|
1265
|
+
const node = nodeOfReport(report);
|
|
1266
|
+
node.reports.push(report);
|
|
1267
|
+
node.total++;
|
|
1268
|
+
if (report.avoidable) {
|
|
1269
|
+
node.avoidable++;
|
|
1270
|
+
totals.avoidable++;
|
|
1271
|
+
totals.perComponent.set(report.component, (totals.perComponent.get(report.component) || 0) + 1);
|
|
1272
|
+
if (typeof report.selfDuration === "number") {
|
|
1273
|
+
node.wasted += report.selfDuration;
|
|
1274
|
+
totals.wasted += report.selfDuration;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
node.lastReport = report;
|
|
1278
|
+
node.flash = true;
|
|
1279
|
+
node.flashAt = Date.now();
|
|
1280
|
+
const ck = report.commitId;
|
|
1281
|
+
let list = state.commits.get(ck);
|
|
1282
|
+
if (!list) {
|
|
1283
|
+
list = [];
|
|
1284
|
+
state.commits.set(ck, list);
|
|
1285
|
+
state.commitOrder.push(ck);
|
|
1286
|
+
if (state.commitOrder.length > MAX_COMMITS) forgetCommit(state.commitOrder.shift());
|
|
1287
|
+
}
|
|
1288
|
+
if (list.length < MAX_PER_COMMIT) {
|
|
1289
|
+
list.push(report);
|
|
1290
|
+
commitMember.add(report);
|
|
1291
|
+
}
|
|
1292
|
+
return node;
|
|
1293
|
+
}
|
|
1294
|
+
function evict() {
|
|
1295
|
+
const excess = state.reports.length - MAX_REPORTS;
|
|
1296
|
+
if (excess <= 0) return;
|
|
1297
|
+
for (const r of state.reports.splice(0, excess)) {
|
|
1298
|
+
if (r.avoidable) {
|
|
1299
|
+
totals.avoidable--;
|
|
1300
|
+
const n = (totals.perComponent.get(r.component) || 0) - 1;
|
|
1301
|
+
if (n > 0) totals.perComponent.set(r.component, n);
|
|
1302
|
+
else totals.perComponent.delete(r.component);
|
|
1303
|
+
if (typeof r.selfDuration === "number") totals.wasted -= r.selfDuration;
|
|
1304
|
+
}
|
|
1305
|
+
if (!commitMember.has(r)) continue;
|
|
1306
|
+
const list = state.commits.get(r.commitId);
|
|
1307
|
+
if (!list || list[0] !== r) continue;
|
|
1308
|
+
list.shift();
|
|
1309
|
+
if (!list.length) {
|
|
1310
|
+
forgetCommit(r.commitId);
|
|
1311
|
+
const i = state.commitOrder.indexOf(r.commitId);
|
|
1312
|
+
if (i >= 0) state.commitOrder.splice(i, 1);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (totals.wasted < 0) totals.wasted = 0;
|
|
1316
|
+
}
|
|
1317
|
+
function flush() {
|
|
1318
|
+
flushScheduled = false;
|
|
1319
|
+
if (!queue.length) return;
|
|
1320
|
+
const batch = queue;
|
|
1321
|
+
queue = [];
|
|
1322
|
+
let touchedSelected = false;
|
|
1323
|
+
let avoidableCount = 0;
|
|
1324
|
+
const touched = /* @__PURE__ */ new Set();
|
|
1325
|
+
for (const r of batch) {
|
|
1326
|
+
const node = ingest(r);
|
|
1327
|
+
touched.add(node);
|
|
1328
|
+
if (state.recording && state.recording.reports.length < MAX_REPORTS) state.recording.reports.push(r);
|
|
1329
|
+
if (r.avoidable) avoidableCount++;
|
|
1330
|
+
if (state.selectedKey === node.key) {
|
|
1331
|
+
touchedSelected = true;
|
|
1332
|
+
if (state.tab === "latest") state.selectedReport = r;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
for (const node of touched) trimNode(node);
|
|
1336
|
+
evict();
|
|
1337
|
+
dataGen++;
|
|
1338
|
+
renderStream(batch);
|
|
1339
|
+
renderSummary();
|
|
1340
|
+
const heavy = state.view === "commits" || state.view === "fixes" || state.tab === "root" || !!(state.recording && state.tab === "session");
|
|
1341
|
+
if (state.view === "tree") {
|
|
1342
|
+
renderTree();
|
|
1343
|
+
if (touchedSelected && !heavy) renderDetails();
|
|
1344
|
+
}
|
|
1345
|
+
scheduleHeavy(heavy || touchedSelected && state.view !== "tree");
|
|
1346
|
+
if (state.polling && avoidableCount) transport.badge?.(totals.avoidable);
|
|
1347
|
+
}
|
|
1348
|
+
let heavyAt = 0;
|
|
1349
|
+
let heavyTimer = null;
|
|
1350
|
+
let heavyDetailsPending = false;
|
|
1351
|
+
function renderHeavy(details2) {
|
|
1352
|
+
heavyAt = Date.now();
|
|
1353
|
+
if (state.view !== "tree") renderLeft();
|
|
1354
|
+
if (details2) renderDetails();
|
|
1355
|
+
}
|
|
1356
|
+
function scheduleHeavy(details2) {
|
|
1357
|
+
const wait = throttled() ? LEFT_RENDER_INTERVAL - (Date.now() - heavyAt) : 0;
|
|
1358
|
+
if (wait <= 0) {
|
|
1359
|
+
if (heavyTimer) clearTimeout(heavyTimer);
|
|
1360
|
+
heavyTimer = null;
|
|
1361
|
+
heavyDetailsPending = false;
|
|
1362
|
+
renderHeavy(details2);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
heavyDetailsPending = heavyDetailsPending || details2;
|
|
1366
|
+
if (!heavyTimer) {
|
|
1367
|
+
heavyTimer = setTimeout(() => {
|
|
1368
|
+
heavyTimer = null;
|
|
1369
|
+
const d = heavyDetailsPending;
|
|
1370
|
+
heavyDetailsPending = false;
|
|
1371
|
+
renderHeavy(d);
|
|
1372
|
+
}, wait);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
function enqueue(report) {
|
|
1376
|
+
queue.push(report);
|
|
1377
|
+
if (!flushScheduled) {
|
|
1378
|
+
flushScheduled = true;
|
|
1379
|
+
schedule(flush);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
function clearAll() {
|
|
1383
|
+
state.tree.children.clear();
|
|
1384
|
+
state.nodesByKey.clear();
|
|
1385
|
+
state.reports = [];
|
|
1386
|
+
state.commits.clear();
|
|
1387
|
+
state.commitOrder = [];
|
|
1388
|
+
commitAnalyses.clear();
|
|
1389
|
+
totals.avoidable = 0;
|
|
1390
|
+
totals.wasted = 0;
|
|
1391
|
+
totals.perComponent.clear();
|
|
1392
|
+
bestFix = void 0;
|
|
1393
|
+
bestFixAt = 0;
|
|
1394
|
+
dataGen++;
|
|
1395
|
+
state.selectedKey = null;
|
|
1396
|
+
state.selectedReport = null;
|
|
1397
|
+
state.selectedCommit = null;
|
|
1398
|
+
state.selectedFix = null;
|
|
1399
|
+
state.selectedRoot = null;
|
|
1400
|
+
if (state.tab === "commit" || state.tab === "fixlist" || state.tab === "root") state.tab = "latest";
|
|
1401
|
+
queue = [];
|
|
1402
|
+
renderLeft();
|
|
1403
|
+
renderDetails();
|
|
1404
|
+
renderStream();
|
|
1405
|
+
renderSummary();
|
|
1406
|
+
}
|
|
1407
|
+
let filterCache = { filter: "", value: null, regex: null, text: "" };
|
|
1408
|
+
function parsedFilter() {
|
|
1409
|
+
if (filterCache.filter === state.filter) return filterCache;
|
|
1410
|
+
const f = state.filter.trim();
|
|
1411
|
+
const value = f.startsWith("~") ? f.slice(1).toLowerCase() : null;
|
|
1412
|
+
let regex = null;
|
|
1413
|
+
const m = value === null ? /^\/(.+)\/([a-z]*)$/.exec(f) : null;
|
|
1414
|
+
if (m && m[1] !== void 0) {
|
|
1415
|
+
try {
|
|
1416
|
+
regex = new RegExp(m[1], (m[2] || "").replace(/[gy]/g, ""));
|
|
1417
|
+
} catch {
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
filterCache = { filter: state.filter, value, regex, text: f.toLowerCase() };
|
|
1421
|
+
return filterCache;
|
|
1422
|
+
}
|
|
1423
|
+
const valueQuery = () => parsedFilter().value;
|
|
1424
|
+
function matchesFilter(name) {
|
|
1425
|
+
const f = parsedFilter();
|
|
1426
|
+
if (!f.filter || f.value !== null) return true;
|
|
1427
|
+
if (f.regex) return f.regex.test(name);
|
|
1428
|
+
return name.toLowerCase().includes(f.text);
|
|
1429
|
+
}
|
|
1430
|
+
const valueCache = /* @__PURE__ */ new WeakMap();
|
|
1431
|
+
function matchesValues(r) {
|
|
1432
|
+
const q = valueQuery();
|
|
1433
|
+
if (q === null) return true;
|
|
1434
|
+
if (!q) return true;
|
|
1435
|
+
let text = valueCache.get(r);
|
|
1436
|
+
if (text === void 0) {
|
|
1437
|
+
try {
|
|
1438
|
+
text = JSON.stringify({ p: r.props.next, h: (r.hookState || []).map((x) => x.value), c: (r.contexts || []).map((x) => x.value), s: r.state ?? null }).toLowerCase();
|
|
1439
|
+
} catch {
|
|
1440
|
+
text = "";
|
|
1441
|
+
}
|
|
1442
|
+
valueCache.set(r, text);
|
|
1443
|
+
}
|
|
1444
|
+
return text.includes(q);
|
|
1445
|
+
}
|
|
1446
|
+
function visible(node) {
|
|
1447
|
+
const own = (!state.avoidableOnly || node.avoidable > 0) && matchesFilter(node.name) && node.total > 0 && (valueQuery() === null || node.reports.some(matchesValues));
|
|
1448
|
+
if (own) return true;
|
|
1449
|
+
for (const c of node.children.values()) if (visible(c)) return true;
|
|
1450
|
+
return false;
|
|
1451
|
+
}
|
|
1452
|
+
const passes = (r) => (!state.avoidableOnly || r.avoidable) && matchesFilter(r.component) && matchesValues(r);
|
|
1453
|
+
let dataGen = 0;
|
|
1454
|
+
let filtered = { key: "", reports: [], fixes: null };
|
|
1455
|
+
function filteredReports() {
|
|
1456
|
+
const key = `${dataGen}|${state.avoidableOnly}|${state.filter}`;
|
|
1457
|
+
if (filtered.key !== key) filtered = { key, reports: state.reports.filter(passes), fixes: null };
|
|
1458
|
+
return filtered.reports;
|
|
1459
|
+
}
|
|
1460
|
+
function filteredFixes() {
|
|
1461
|
+
const reports = filteredReports();
|
|
1462
|
+
if (!filtered.fixes) filtered.fixes = rankFixes(reports);
|
|
1463
|
+
return filtered.fixes;
|
|
1464
|
+
}
|
|
1465
|
+
function setView(view) {
|
|
1466
|
+
state.view = view;
|
|
1467
|
+
for (const [id, b] of viewButtons) b.classList.toggle("active", id === view);
|
|
1468
|
+
tree.hidden = view !== "tree";
|
|
1469
|
+
table.hidden = view === "tree";
|
|
1470
|
+
renderLeft();
|
|
1471
|
+
renderDetails();
|
|
1472
|
+
persist();
|
|
1473
|
+
}
|
|
1474
|
+
function renderLeft() {
|
|
1475
|
+
if (state.view === "tree") renderTree();
|
|
1476
|
+
else if (state.view === "offenders") renderOffenders();
|
|
1477
|
+
else if (state.view === "commits") renderCommits();
|
|
1478
|
+
else if (state.view === "sessions") renderSessions();
|
|
1479
|
+
else renderFixes();
|
|
1480
|
+
}
|
|
1481
|
+
const rowEls = /* @__PURE__ */ new Map();
|
|
1482
|
+
const treeList = virtualList(tree, ROW_H, ({ node, depth }) => rowFor(node, depth));
|
|
1483
|
+
const emptyEl = el("div", { class: "empty" }, [
|
|
1484
|
+
el("div", { text: "No re-renders reported yet." }),
|
|
1485
|
+
el("div", null, ["Call ", el("code", { text: "init({ notifier: createDevtoolsNotifier() })" }), " in the page, or enable injection in Settings, then interact with it."])
|
|
1486
|
+
]);
|
|
1487
|
+
function renderTree() {
|
|
1488
|
+
const flat = [];
|
|
1489
|
+
const walk = (node, depth) => {
|
|
1490
|
+
for (const child of node.children.values()) {
|
|
1491
|
+
if (!visible(child)) continue;
|
|
1492
|
+
flat.push({ node: child, depth });
|
|
1493
|
+
if (child.expanded) walk(child, depth + 1);
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
walk(state.tree, 0);
|
|
1497
|
+
if (flat.length === 0) {
|
|
1498
|
+
if (!emptyEl.parentNode) tree.append(emptyEl);
|
|
1499
|
+
} else emptyEl.remove();
|
|
1500
|
+
if (rowEls.size > flat.length * 2 + 64) {
|
|
1501
|
+
const live = new Set(flat.map((f) => f.node.key));
|
|
1502
|
+
for (const key of [...rowEls.keys()]) if (!live.has(key)) rowEls.delete(key);
|
|
1503
|
+
}
|
|
1504
|
+
treeList.setItems(flat);
|
|
1505
|
+
}
|
|
1506
|
+
function hoverHighlight(node, on) {
|
|
1507
|
+
const r = node.lastReport;
|
|
1508
|
+
if (!r || !r.instanceId) return;
|
|
1509
|
+
transport.highlight?.(on ? r.instanceId : null);
|
|
1510
|
+
}
|
|
1511
|
+
function rowFor(node, depth) {
|
|
1512
|
+
let row = rowEls.get(node.key);
|
|
1513
|
+
if (!row) {
|
|
1514
|
+
const created = el("div", {
|
|
1515
|
+
class: "row",
|
|
1516
|
+
"data-key": node.key,
|
|
1517
|
+
role: "treeitem",
|
|
1518
|
+
onclick: () => select(node),
|
|
1519
|
+
onmouseenter: () => hoverHighlight(node, true),
|
|
1520
|
+
onmouseleave: () => hoverHighlight(node, false),
|
|
1521
|
+
onanimationend: () => created.classList.remove("flash")
|
|
1522
|
+
});
|
|
1523
|
+
created.append(el("span", { class: "indent" }));
|
|
1524
|
+
created.append(
|
|
1525
|
+
el("span", {
|
|
1526
|
+
class: "chevron",
|
|
1527
|
+
onclick: (e) => {
|
|
1528
|
+
e.stopPropagation();
|
|
1529
|
+
toggleExpanded(node);
|
|
1530
|
+
}
|
|
1531
|
+
})
|
|
1532
|
+
);
|
|
1533
|
+
created.append(
|
|
1534
|
+
el("span", { class: "tag" }, [el("span", { class: "bracket", text: "<" }), el("span", { class: "name", text: node.name }), el("span", { class: "bracket", text: ">" })])
|
|
1535
|
+
);
|
|
1536
|
+
created.append(el("span", { class: "badges" }));
|
|
1537
|
+
rowEls.set(node.key, created);
|
|
1538
|
+
row = created;
|
|
1539
|
+
}
|
|
1540
|
+
row.classList.toggle("selected", state.selectedKey === node.key);
|
|
1541
|
+
row.setAttribute("aria-selected", state.selectedKey === node.key ? "true" : "false");
|
|
1542
|
+
row.setAttribute("aria-level", String(depth + 1));
|
|
1543
|
+
const indent = row.querySelector(".indent");
|
|
1544
|
+
if (indent.childElementCount !== depth) {
|
|
1545
|
+
indent.textContent = "";
|
|
1546
|
+
for (let i = 0; i < depth; i++) indent.append(el("span", { class: "guide" }));
|
|
1547
|
+
}
|
|
1548
|
+
const hasChildren = [...node.children.values()].some(visible);
|
|
1549
|
+
const chevron = row.querySelector(".chevron");
|
|
1550
|
+
chevron.classList.toggle("leaf", !hasChildren);
|
|
1551
|
+
chevron.textContent = node.expanded ? "\u25BE" : "\u25B8";
|
|
1552
|
+
const badges = row.querySelector(".badges");
|
|
1553
|
+
badges.textContent = "";
|
|
1554
|
+
if (node.avoidable) badges.append(el("span", { class: "badge avoid", title: "avoidable re-renders", text: String(node.avoidable) }));
|
|
1555
|
+
if (node.total) badges.append(el("span", { class: "badge", title: "re-renders", text: String(node.total) }));
|
|
1556
|
+
if (node.flash) {
|
|
1557
|
+
node.flash = false;
|
|
1558
|
+
if (Date.now() - (node.flashAt || 0) < 1e3) {
|
|
1559
|
+
row.classList.remove("flash");
|
|
1560
|
+
void row.offsetWidth;
|
|
1561
|
+
row.classList.add("flash");
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
return row;
|
|
1565
|
+
}
|
|
1566
|
+
function toggleExpanded(node, value) {
|
|
1567
|
+
node.expanded = value === void 0 ? !node.expanded : value;
|
|
1568
|
+
if (node.expanded) state.collapsed.delete(node.key);
|
|
1569
|
+
else state.collapsed.add(node.key);
|
|
1570
|
+
renderTree();
|
|
1571
|
+
persist();
|
|
1572
|
+
}
|
|
1573
|
+
function select(node, report) {
|
|
1574
|
+
state.selectedKey = node.key;
|
|
1575
|
+
state.selectedReport = report || node.lastReport || null;
|
|
1576
|
+
if (report && state.tab !== "fix") state.tab = "latest";
|
|
1577
|
+
if (state.tab === "commit" || state.tab === "fixlist" || state.tab === "root") state.tab = "latest";
|
|
1578
|
+
renderLeft();
|
|
1579
|
+
renderDetails();
|
|
1580
|
+
if (state.view === "tree") {
|
|
1581
|
+
const idx = treeList.items.findIndex((f) => f.node.key === node.key);
|
|
1582
|
+
if (idx >= 0) treeList.scrollTo(idx);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
function onTreeKey(e) {
|
|
1586
|
+
const rows = treeList.items;
|
|
1587
|
+
if (!rows.length) return;
|
|
1588
|
+
const idx = rows.findIndex((f) => f.node.key === state.selectedKey);
|
|
1589
|
+
if (e.key === "ArrowDown") {
|
|
1590
|
+
e.preventDefault();
|
|
1591
|
+
select(rows[Math.min(rows.length - 1, idx + 1)].node);
|
|
1592
|
+
} else if (e.key === "ArrowUp") {
|
|
1593
|
+
e.preventDefault();
|
|
1594
|
+
select(rows[Math.max(0, idx - 1)].node);
|
|
1595
|
+
} else if (e.key === "ArrowRight" && idx >= 0) {
|
|
1596
|
+
toggleExpanded(rows[idx].node, true);
|
|
1597
|
+
} else if (e.key === "ArrowLeft" && idx >= 0) {
|
|
1598
|
+
toggleExpanded(rows[idx].node, false);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
function onGlobalKey(e) {
|
|
1602
|
+
const target = e.target;
|
|
1603
|
+
const inField = !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");
|
|
1604
|
+
if (e.key === "Escape") {
|
|
1605
|
+
transport.highlight?.(null);
|
|
1606
|
+
if (state.settingsOpen && settings.contains(target)) {
|
|
1607
|
+
toggleSettings(false);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (inField) target.blur();
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1614
|
+
if (e.key === "/") {
|
|
1615
|
+
e.preventDefault();
|
|
1616
|
+
search.focus();
|
|
1617
|
+
search.select();
|
|
1618
|
+
} else if (e.key === "f" && state.selectedKey) {
|
|
1619
|
+
e.preventDefault();
|
|
1620
|
+
state.tab = "fix";
|
|
1621
|
+
renderDetails();
|
|
1622
|
+
persist();
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function sortableHeader(label, key, numeric = false) {
|
|
1626
|
+
const active = state.sort.key === key;
|
|
1627
|
+
return el(
|
|
1628
|
+
"th",
|
|
1629
|
+
{
|
|
1630
|
+
class: (active ? "sorted " : "") + (numeric ? "num" : ""),
|
|
1631
|
+
onclick: () => {
|
|
1632
|
+
state.sort = { key, dir: active ? -state.sort.dir : numeric ? -1 : 1 };
|
|
1633
|
+
renderLeft();
|
|
1634
|
+
}
|
|
1635
|
+
},
|
|
1636
|
+
label + (active ? state.sort.dir < 0 ? " \u25BE" : " \u25B4" : "")
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
function offenderRows() {
|
|
1640
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1641
|
+
for (const r of filteredReports()) {
|
|
1642
|
+
let o = byName.get(r.component);
|
|
1643
|
+
if (!o) {
|
|
1644
|
+
o = { component: r.component, total: 0, avoidable: 0, wasted: 0, paths: /* @__PURE__ */ new Set(), reports: [], fix: "" };
|
|
1645
|
+
byName.set(r.component, o);
|
|
1646
|
+
}
|
|
1647
|
+
o.total++;
|
|
1648
|
+
if (r.avoidable) {
|
|
1649
|
+
o.avoidable++;
|
|
1650
|
+
if (typeof r.selfDuration === "number") o.wasted += r.selfDuration;
|
|
1651
|
+
}
|
|
1652
|
+
o.paths.add(keyOf(r.path));
|
|
1653
|
+
o.reports.push(r);
|
|
1654
|
+
}
|
|
1655
|
+
const rows = [...byName.values()];
|
|
1656
|
+
for (const o of rows) {
|
|
1657
|
+
const fixes = o.avoidable ? rankFixes(o.reports) : [];
|
|
1658
|
+
o.fix = fixes.length ? fixes[0].label : "";
|
|
1659
|
+
}
|
|
1660
|
+
const { key, dir } = state.sort;
|
|
1661
|
+
rows.sort((a, b) => {
|
|
1662
|
+
const va = a[key];
|
|
1663
|
+
const vb = b[key];
|
|
1664
|
+
const c = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
|
|
1665
|
+
return c * dir || b.avoidable - a.avoidable;
|
|
1666
|
+
});
|
|
1667
|
+
return rows;
|
|
1668
|
+
}
|
|
1669
|
+
function offenderRow(o) {
|
|
1670
|
+
return el(
|
|
1671
|
+
"tr",
|
|
1672
|
+
{
|
|
1673
|
+
class: o.avoidable ? "has-avoid" : "",
|
|
1674
|
+
onclick: () => {
|
|
1675
|
+
const last = o.reports[o.reports.length - 1];
|
|
1676
|
+
state.tab = "fix";
|
|
1677
|
+
select(nodeOfReport(last), last);
|
|
1678
|
+
}
|
|
1679
|
+
},
|
|
1680
|
+
[
|
|
1681
|
+
el("td", { class: "c" }, [el("span", { class: "name", text: o.component }), o.paths.size > 1 ? el("span", { class: "meta", text: ` \xD7${o.paths.size} places` }) : null]),
|
|
1682
|
+
el("td", { class: "num" }, o.avoidable ? el("span", { class: "badge avoid", text: String(o.avoidable) }) : "0"),
|
|
1683
|
+
el("td", { class: "num", text: String(o.total) }),
|
|
1684
|
+
el("td", { class: "num", text: o.wasted ? fmtMs(o.wasted) : "" }),
|
|
1685
|
+
el("td", { class: "fix", text: o.fix })
|
|
1686
|
+
]
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
function renderOffenders() {
|
|
1690
|
+
table.textContent = "";
|
|
1691
|
+
const rows = offenderRows();
|
|
1692
|
+
if (!rows.length) {
|
|
1693
|
+
table.append(el("div", { class: "empty", text: "No re-renders reported yet." }));
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const t = el("table", { class: "grid" });
|
|
1697
|
+
t.append(el("thead", null, el("tr", null, [sortableHeader("Component", "component"), sortableHeader("Avoidable", "avoidable", true), sortableHeader("Total", "total", true), sortableHeader("Wasted", "wasted", true), el("th", { text: "Top fix" })])));
|
|
1698
|
+
const body = el("tbody");
|
|
1699
|
+
for (const o of rows) body.append(offenderRow(o));
|
|
1700
|
+
t.append(body);
|
|
1701
|
+
table.append(t);
|
|
1702
|
+
}
|
|
1703
|
+
function commitSummaries() {
|
|
1704
|
+
const out = [];
|
|
1705
|
+
for (let i = state.commitOrder.length - 1; i >= 0; i--) {
|
|
1706
|
+
const key = state.commitOrder[i];
|
|
1707
|
+
const reports = state.commits.get(key);
|
|
1708
|
+
if (!reports || !reports.some(passes)) continue;
|
|
1709
|
+
out.push({ key, analysis: analysisFor(key, reports) });
|
|
1710
|
+
}
|
|
1711
|
+
return out;
|
|
1712
|
+
}
|
|
1713
|
+
function showCommit(key) {
|
|
1714
|
+
state.selectedCommit = key;
|
|
1715
|
+
state.tab = "commit";
|
|
1716
|
+
renderLeft();
|
|
1717
|
+
renderDetails();
|
|
1718
|
+
}
|
|
1719
|
+
function showRoot(name) {
|
|
1720
|
+
state.selectedRoot = name;
|
|
1721
|
+
state.tab = "root";
|
|
1722
|
+
renderDetails();
|
|
1723
|
+
}
|
|
1724
|
+
function renderCommits() {
|
|
1725
|
+
table.textContent = "";
|
|
1726
|
+
const items = commitSummaries();
|
|
1727
|
+
if (!items.length) {
|
|
1728
|
+
table.append(el("div", { class: "empty", text: "No commits yet." }));
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const list = el("ul", { class: "commits" });
|
|
1732
|
+
for (const { key, analysis } of items) {
|
|
1733
|
+
const root2 = analysis.roots[0];
|
|
1734
|
+
list.append(
|
|
1735
|
+
el("li", { class: (state.selectedCommit === key && state.tab === "commit" ? "selected " : "") + (analysis.avoidable ? "has-avoid" : ""), onclick: () => showCommit(key) }, [
|
|
1736
|
+
el("span", { class: "id", text: `#${key}` }),
|
|
1737
|
+
el("span", { class: "t", text: fmtTime(analysis.receivedAt) }),
|
|
1738
|
+
el("span", { class: "n", text: plural(analysis.total, "render") }),
|
|
1739
|
+
analysis.avoidable ? el("span", { class: "badge avoid", text: `${analysis.avoidable} avoidable` }) : el("span", { class: "badge", text: "ok" }),
|
|
1740
|
+
analysis.reports[0]?.commitPriority ? el("span", { class: "prio " + analysis.reports[0].commitPriority, title: "commit priority", text: PRIORITY_LABEL[analysis.reports[0].commitPriority] || analysis.reports[0].commitPriority }) : null,
|
|
1741
|
+
analysis.reports[0]?.commitCause === "effect-after-commit" ? el("span", { class: "cause effect", title: "state set right after the previous commit (effect \u2192 setState)", text: `effect loop \u2190 #${analysis.reports[0].afterCommit ?? "?"}` }) : analysis.reports[0]?.commitCause === "suspense-resolved" ? el("span", { class: "cause suspense", text: "suspense resolved" }) : null,
|
|
1742
|
+
el("span", {
|
|
1743
|
+
class: "root",
|
|
1744
|
+
text: root2 ? `\u2190 <${root2.name}> (${root2.trigger})` : analysis.reports[0]?.updaters?.length ? `\u2190 set by ${analysis.reports[0].updaters.map((u) => `<${u}>`).join(", ")}` : ""
|
|
1745
|
+
})
|
|
1746
|
+
])
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
table.append(list);
|
|
1750
|
+
}
|
|
1751
|
+
function fixItem(f) {
|
|
1752
|
+
const selected = state.selectedFix === f.key && state.tab === "fixlist";
|
|
1753
|
+
return el(
|
|
1754
|
+
"li",
|
|
1755
|
+
{
|
|
1756
|
+
class: selected ? "selected" : "",
|
|
1757
|
+
"aria-selected": selected ? "true" : null,
|
|
1758
|
+
onclick: () => {
|
|
1759
|
+
state.selectedFix = f.key;
|
|
1760
|
+
state.tab = "fixlist";
|
|
1761
|
+
renderLeft();
|
|
1762
|
+
renderDetails();
|
|
1763
|
+
}
|
|
1764
|
+
},
|
|
1765
|
+
[
|
|
1766
|
+
el("span", { class: "badge avoid", title: "avoidable re-renders removed", text: String(f.count) }),
|
|
1767
|
+
el("span", { class: "label", text: f.label }),
|
|
1768
|
+
el("span", { class: "meta", text: componentList(f.components) })
|
|
1769
|
+
]
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
function renderFixes() {
|
|
1773
|
+
table.textContent = "";
|
|
1774
|
+
const reports = filteredReports();
|
|
1775
|
+
const fixes = filteredFixes();
|
|
1776
|
+
const contexts = contextAttribution(reports);
|
|
1777
|
+
if (!fixes.length && !contexts.length) {
|
|
1778
|
+
table.append(el("div", { class: "empty", text: "No avoidable re-renders, nothing to fix." }));
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
if (fixes.length) {
|
|
1782
|
+
const list = el("ol", { class: "fixes", role: "list" });
|
|
1783
|
+
for (const f of fixes) list.append(fixItem(f));
|
|
1784
|
+
table.append(el("div", { class: "section-title", text: "Ranked by avoidable re-renders removed" }), list);
|
|
1785
|
+
}
|
|
1786
|
+
if (contexts.length) {
|
|
1787
|
+
const list = el("ul", { class: "contexts" });
|
|
1788
|
+
for (const c of contexts) {
|
|
1789
|
+
const partial = c.changedKeys.size > 0 && c.totalKeys > c.changedKeys.size;
|
|
1790
|
+
list.append(
|
|
1791
|
+
el("li", null, [
|
|
1792
|
+
el("span", { class: "name", text: c.name }),
|
|
1793
|
+
c.providers.size ? el("span", { class: "meta", text: ` (provided by ${componentList(c.providers)})` }) : null,
|
|
1794
|
+
el("span", {
|
|
1795
|
+
class: "meta",
|
|
1796
|
+
text: ` changed in ${plural(c.commits.size, "commit")}, ${plural(c.consumers, "consumer re-render")}${c.avoidable ? `, ${c.avoidable} with an equal value` : ""}: ${componentList(c.components)}`
|
|
1797
|
+
}),
|
|
1798
|
+
partial ? el("div", { class: "meta hint", text: `only ${[...c.changedKeys].join(", ")} of ${c.totalKeys} keys changed: consumers of the other keys re-render for nothing. Split the context or select slices.` }) : null
|
|
1799
|
+
])
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
table.append(el("div", { class: "section-title", text: "Contexts" }), list);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
function renderDetails() {
|
|
1806
|
+
const openLabels = new Set([...details.querySelectorAll("details.obj[open] > summary")].map((x) => x.textContent));
|
|
1807
|
+
details.textContent = "";
|
|
1808
|
+
if (state.tab === "commit" && state.selectedCommit !== null) renderCommitDetails();
|
|
1809
|
+
else if (state.tab === "fixlist" && state.selectedFix) renderFixDetails();
|
|
1810
|
+
else if (state.tab === "root" && state.selectedRoot) renderRootDetails();
|
|
1811
|
+
else if (state.tab === "session" && state.selectedSession) renderSessionDetails();
|
|
1812
|
+
else renderNodeDetails(state.selectedKey ? state.nodesByKey.get(state.selectedKey) ?? null : null);
|
|
1813
|
+
if (openLabels.size) {
|
|
1814
|
+
for (const d of details.querySelectorAll("details.obj")) {
|
|
1815
|
+
if (openLabels.has(d.querySelector("summary").textContent)) d.open = true;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
const reportActions = () => ({
|
|
1820
|
+
openSource: transport.openResource ? (src) => transport.openResource(src.fileName, src.lineNumber, src.columnNumber) : null,
|
|
1821
|
+
highlight: transport.highlight ? (id) => transport.highlight(id) : null,
|
|
1822
|
+
copy: copyText,
|
|
1823
|
+
readSource: transport.readSource ? (url) => transport.readSource(url) : null
|
|
1824
|
+
});
|
|
1825
|
+
const tabButton = (id, label, onclick) => el("button", { class: state.tab === id ? "active" : "", onclick }, label);
|
|
1826
|
+
function renderNodeDetails(node) {
|
|
1827
|
+
if (!node) {
|
|
1828
|
+
details.append(el("div", { class: "empty", text: "Select a component to see why it re-rendered." }));
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
const header = el("div", { class: "details-header" }, [
|
|
1832
|
+
el("span", { class: "title" }, [el("span", { class: "bracket", text: "<" }), el("span", { class: "name", text: node.name }), el("span", { class: "bracket", text: ">" })]),
|
|
1833
|
+
el("span", { class: "meta", text: `${plural(node.total, "re-render")}, ${node.avoidable} avoidable${node.wasted ? ", " + fmtMs(node.wasted) + " wasted" : ""}` }),
|
|
1834
|
+
el("span", { class: "tabs" }, [
|
|
1835
|
+
tabButton("latest", "Report", () => {
|
|
1836
|
+
state.tab = "latest";
|
|
1837
|
+
state.selectedReport = node.lastReport ?? null;
|
|
1838
|
+
renderDetails();
|
|
1839
|
+
persist();
|
|
1840
|
+
}),
|
|
1841
|
+
tabButton("history", `History (${node.reports.length})`, () => {
|
|
1842
|
+
state.tab = "history";
|
|
1843
|
+
renderDetails();
|
|
1844
|
+
persist();
|
|
1845
|
+
}),
|
|
1846
|
+
tabButton("fix", "Fix", () => {
|
|
1847
|
+
state.tab = "fix";
|
|
1848
|
+
renderDetails();
|
|
1849
|
+
persist();
|
|
1850
|
+
})
|
|
1851
|
+
])
|
|
1852
|
+
]);
|
|
1853
|
+
details.append(header);
|
|
1854
|
+
const body = el("div", { class: "details-body" });
|
|
1855
|
+
details.append(body);
|
|
1856
|
+
if (state.tab === "history") {
|
|
1857
|
+
const list = el("ul", { class: "history" });
|
|
1858
|
+
for (const r2 of [...node.reports].reverse()) {
|
|
1859
|
+
list.append(
|
|
1860
|
+
el(
|
|
1861
|
+
"li",
|
|
1862
|
+
{
|
|
1863
|
+
class: state.selectedReport === r2 ? "selected" : "",
|
|
1864
|
+
onclick: () => {
|
|
1865
|
+
state.selectedReport = r2;
|
|
1866
|
+
state.tab = "latest";
|
|
1867
|
+
renderDetails();
|
|
1868
|
+
}
|
|
1869
|
+
},
|
|
1870
|
+
[
|
|
1871
|
+
el("span", { class: "t", text: fmtTime(r2.receivedAt) }),
|
|
1872
|
+
el("span", { class: "n", text: "#" + r2.renderCount }),
|
|
1873
|
+
el("span", { class: "verdict " + (r2.avoidable ? "avoid" : "ok"), text: r2.avoidable ? "avoidable" : r2.trigger }),
|
|
1874
|
+
el("span", { class: "sum", text: summarize(r2) })
|
|
1875
|
+
]
|
|
1876
|
+
)
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
body.append(list);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (state.tab === "fix") {
|
|
1883
|
+
body.append(fixView(rankFixes(node.reports), { copy: copyText }));
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const r = state.selectedReport || node.lastReport;
|
|
1887
|
+
if (!r) return;
|
|
1888
|
+
body.append(reportView(r, reportActions()));
|
|
1889
|
+
}
|
|
1890
|
+
function rootsList(roots) {
|
|
1891
|
+
return el(
|
|
1892
|
+
"ul",
|
|
1893
|
+
{ class: "roots" },
|
|
1894
|
+
roots.map(
|
|
1895
|
+
(root2) => el("li", null, [
|
|
1896
|
+
el("a", { class: "root-link", href: "#", title: "Every commit this component started", onclick: (e) => (e.preventDefault(), showRoot(root2.name)) }, `<${root2.name}>`),
|
|
1897
|
+
` (${root2.trigger}) \u2192 ${plural(root2.count, "avoidable re-render")}: `,
|
|
1898
|
+
el("span", { class: "meta", text: componentList(root2.components) })
|
|
1899
|
+
])
|
|
1900
|
+
)
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
function renderCommitDetails() {
|
|
1904
|
+
const key = state.selectedCommit;
|
|
1905
|
+
const reports = state.commits.get(key);
|
|
1906
|
+
if (!reports) {
|
|
1907
|
+
details.append(el("div", { class: "empty", text: "This commit is no longer buffered." }));
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const a = analysisFor(key, reports);
|
|
1911
|
+
details.append(
|
|
1912
|
+
el("div", { class: "details-header" }, [
|
|
1913
|
+
el("span", { class: "title", text: `Commit #${key}` }),
|
|
1914
|
+
el("span", { class: "meta", text: `${plural(a.total, "render")}, ${a.avoidable} avoidable${a.wasted ? ", " + fmtMs(a.wasted) + " wasted" : ""} \xB7 ${fmtTime(a.receivedAt)}` })
|
|
1915
|
+
])
|
|
1916
|
+
);
|
|
1917
|
+
const body = el("div", { class: "details-body" });
|
|
1918
|
+
details.append(body);
|
|
1919
|
+
if (a.roots.length) body.append(el("div", { class: "section" }, [el("h3", { text: "Root causes" }), rootsList(a.roots)]));
|
|
1920
|
+
if (a.contexts.length) {
|
|
1921
|
+
body.append(
|
|
1922
|
+
el("div", { class: "section" }, [
|
|
1923
|
+
el("h3", { text: "Contexts that changed" }),
|
|
1924
|
+
el("ul", { class: "roots" }, a.contexts.map((c) => el("li", null, [el("b", { text: c.name }), ` \u2192 ${plural(c.consumers, "consumer")} re-rendered${c.avoidable ? ` (${c.avoidable} with an equal value)` : ""}`])))
|
|
1925
|
+
])
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
const cascade = el("div", { class: "cascade" });
|
|
1929
|
+
const walk = (node, depth) => {
|
|
1930
|
+
for (const child of node.children.values()) {
|
|
1931
|
+
const r = child.report;
|
|
1932
|
+
const line = el(
|
|
1933
|
+
"div",
|
|
1934
|
+
{
|
|
1935
|
+
class: "cascade-row" + (r ? r.avoidable ? " avoid" : " ok" : " untracked"),
|
|
1936
|
+
style: `padding-left:${depth * 14}px`,
|
|
1937
|
+
onclick: r ? () => select(nodeOfReport(r), r) : null
|
|
1938
|
+
},
|
|
1939
|
+
[
|
|
1940
|
+
el("span", { class: "tag" }, [el("span", { class: "bracket", text: "<" }), el("span", { class: "name", text: child.name }), el("span", { class: "bracket", text: ">" })]),
|
|
1941
|
+
r ? el("span", { class: "verdict " + (r.avoidable ? "avoid" : "ok"), text: r.avoidable ? "avoidable" : r.trigger }) : el("span", { class: "meta", text: "did not render or untracked" }),
|
|
1942
|
+
child.count && child.count > 1 ? el("span", { class: "meta", text: ` \xD7${child.count}` }) : null,
|
|
1943
|
+
r && r.avoidable ? el("span", { class: "meta", text: " " + summarize(r) }) : null
|
|
1944
|
+
]
|
|
1945
|
+
);
|
|
1946
|
+
cascade.append(line);
|
|
1947
|
+
walk(child, depth + 1);
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
walk(cascadeTree(reports), 0);
|
|
1951
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Render cascade" }), cascade]));
|
|
1952
|
+
if (a.fixes.length) {
|
|
1953
|
+
body.append(el("div", { class: "section-title", text: "Fixes for this commit" }));
|
|
1954
|
+
body.append(fixView(a.fixes, { copy: copyText }));
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
function affectedList(reports) {
|
|
1958
|
+
const list = el("ul", { class: "history" });
|
|
1959
|
+
for (const r of reports.slice().reverse()) {
|
|
1960
|
+
list.append(
|
|
1961
|
+
el("li", { onclick: () => select(nodeOfReport(r), r) }, [
|
|
1962
|
+
el("span", { class: "t", text: fmtTime(r.receivedAt) }),
|
|
1963
|
+
el("span", { class: "comp", text: `<${r.component}>` }),
|
|
1964
|
+
el("span", { class: "sum", text: summarize(r) })
|
|
1965
|
+
])
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
return list;
|
|
1969
|
+
}
|
|
1970
|
+
function renderFixDetails() {
|
|
1971
|
+
const fix = filteredFixes().find((f) => f.key === state.selectedFix);
|
|
1972
|
+
if (!fix) {
|
|
1973
|
+
details.append(el("div", { class: "empty", text: "Select a fix." }));
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
details.append(el("div", { class: "details-header" }, [el("span", { class: "title", text: fix.label }), el("span", { class: "meta", text: `removes ${plural(fix.count, "avoidable re-render")}` })]));
|
|
1977
|
+
const body = el("div", { class: "details-body" });
|
|
1978
|
+
details.append(body);
|
|
1979
|
+
body.append(fixView([fix], { copy: copyText }));
|
|
1980
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Affected re-renders" }), affectedList(fix.reports)]));
|
|
1981
|
+
}
|
|
1982
|
+
function renderRootDetails() {
|
|
1983
|
+
const name = state.selectedRoot;
|
|
1984
|
+
const s = rootCauseSummary(name, state.commits, analysisFor);
|
|
1985
|
+
details.append(
|
|
1986
|
+
el("div", { class: "details-header" }, [
|
|
1987
|
+
el("span", { class: "title" }, ["Root cause ", el("span", { class: "name", text: `<${name}>` })]),
|
|
1988
|
+
el("span", { class: "meta", text: s.commits.length ? `started ${plural(s.commits.length, "commit")} with ${plural(s.total, "avoidable re-render")} (${s.trigger})` : "no commits in the buffer" })
|
|
1989
|
+
])
|
|
1990
|
+
);
|
|
1991
|
+
const body = el("div", { class: "details-body" });
|
|
1992
|
+
details.append(body);
|
|
1993
|
+
if (!s.commits.length) return;
|
|
1994
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Components that re-rendered avoidably because of it" }), el("div", { class: "meta", text: componentList(s.components) })]));
|
|
1995
|
+
const list = el("ul", { class: "commits root-commits" });
|
|
1996
|
+
for (const c of s.commits) {
|
|
1997
|
+
list.append(
|
|
1998
|
+
el("li", { onclick: () => showCommit(c.key) }, [
|
|
1999
|
+
el("span", { class: "id", text: `#${c.key}` }),
|
|
2000
|
+
el("span", { class: "t", text: fmtTime(c.analysis.receivedAt) }),
|
|
2001
|
+
el("span", { class: "badge avoid", text: `${c.count} avoidable` }),
|
|
2002
|
+
el("span", { class: "root", text: componentList(c.components) })
|
|
2003
|
+
])
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Commits" }), list]));
|
|
2007
|
+
if (s.fixes.length) {
|
|
2008
|
+
body.append(el("div", { class: "section-title", text: "Fixes" }));
|
|
2009
|
+
body.append(fixView(s.fixes, { copy: copyText }));
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
const sessionSummary = (s) => summarizeSession(s, s.reports);
|
|
2013
|
+
function persistSessions() {
|
|
2014
|
+
if (!transport.storage) return;
|
|
2015
|
+
const summaries = state.sessions.filter((s) => s.endedAt).slice(-20).map(sessionSummary);
|
|
2016
|
+
transport.storage.set("sessions", summaries);
|
|
2017
|
+
}
|
|
2018
|
+
function startRecording(name) {
|
|
2019
|
+
if (state.recording) stopRecording();
|
|
2020
|
+
const startedAt = Date.now();
|
|
2021
|
+
const session = {
|
|
2022
|
+
id: `s${startedAt.toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
2023
|
+
name: name || `Session ${state.sessions.length + 1}`,
|
|
2024
|
+
startedAt,
|
|
2025
|
+
endedAt: null,
|
|
2026
|
+
total: 0,
|
|
2027
|
+
avoidable: 0,
|
|
2028
|
+
wasted: 0,
|
|
2029
|
+
byComponent: {},
|
|
2030
|
+
fixes: [],
|
|
2031
|
+
reports: []
|
|
2032
|
+
};
|
|
2033
|
+
state.sessions.push(session);
|
|
2034
|
+
state.recording = session;
|
|
2035
|
+
recordBtn.classList.add("active", "rec");
|
|
2036
|
+
recordBtn.querySelector(".glyph").textContent = "\u23F9";
|
|
2037
|
+
recordBtn.querySelector(".label").textContent = "Stop";
|
|
2038
|
+
state.selectedSession = session.id;
|
|
2039
|
+
state.tab = "session";
|
|
2040
|
+
if (state.view === "sessions") renderLeft();
|
|
2041
|
+
renderDetails();
|
|
2042
|
+
toast(`Recording ${session.name}`);
|
|
2043
|
+
return session;
|
|
2044
|
+
}
|
|
2045
|
+
function stopRecording() {
|
|
2046
|
+
const session = state.recording;
|
|
2047
|
+
if (!session) return null;
|
|
2048
|
+
session.endedAt = Date.now();
|
|
2049
|
+
Object.assign(session, sessionSummary(session), { reports: session.reports });
|
|
2050
|
+
state.recording = null;
|
|
2051
|
+
recordBtn.classList.remove("active", "rec");
|
|
2052
|
+
recordBtn.querySelector(".glyph").textContent = "\u23FA";
|
|
2053
|
+
recordBtn.querySelector(".label").textContent = "Record";
|
|
2054
|
+
persistSessions();
|
|
2055
|
+
if (state.view === "sessions") renderLeft();
|
|
2056
|
+
if (state.tab === "session") renderDetails();
|
|
2057
|
+
toast(`${session.name}: ${plural(session.avoidable, "avoidable re-render")}`);
|
|
2058
|
+
return session;
|
|
2059
|
+
}
|
|
2060
|
+
const fmtDuration = (s) => {
|
|
2061
|
+
const ms = (s.endedAt || Date.now()) - s.startedAt;
|
|
2062
|
+
return ms < 6e4 ? `${(ms / 1e3).toFixed(ms < 1e4 ? 1 : 0)} s` : `${Math.round(ms / 6e4)} min`;
|
|
2063
|
+
};
|
|
2064
|
+
function renderSessions() {
|
|
2065
|
+
table.textContent = "";
|
|
2066
|
+
if (!state.sessions.length) {
|
|
2067
|
+
table.append(
|
|
2068
|
+
el("div", { class: "empty" }, [
|
|
2069
|
+
el("div", { text: "No sessions yet." }),
|
|
2070
|
+
el("div", null, ["Press ", el("code", { text: "Record" }), ", use the app, press ", el("code", { text: "Stop" }), ". Apply a fix, record again, and compare the two."])
|
|
2071
|
+
])
|
|
2072
|
+
);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const list = el("ul", { class: "sessions" });
|
|
2076
|
+
for (const s of [...state.sessions].reverse()) {
|
|
2077
|
+
const live = s === state.recording;
|
|
2078
|
+
const summary2 = live ? sessionSummary(s) : s;
|
|
2079
|
+
list.append(
|
|
2080
|
+
el(
|
|
2081
|
+
"li",
|
|
2082
|
+
{
|
|
2083
|
+
class: (state.selectedSession === s.id && state.tab === "session" ? "selected " : "") + (live ? "live" : ""),
|
|
2084
|
+
onclick: () => {
|
|
2085
|
+
state.selectedSession = s.id;
|
|
2086
|
+
state.tab = "session";
|
|
2087
|
+
renderLeft();
|
|
2088
|
+
renderDetails();
|
|
2089
|
+
}
|
|
2090
|
+
},
|
|
2091
|
+
[
|
|
2092
|
+
el("span", { class: "name", text: s.name }),
|
|
2093
|
+
live ? el("span", { class: "badge rec", text: "recording" }) : el("span", { class: "t", text: fmtDuration(summary2) }),
|
|
2094
|
+
el("span", { class: "n", text: plural(summary2.total, "render") }),
|
|
2095
|
+
summary2.avoidable ? el("span", { class: "badge avoid", text: `${summary2.avoidable} avoidable` }) : el("span", { class: "badge", text: "clean" }),
|
|
2096
|
+
summary2.wasted ? el("span", { class: "meta", text: fmtMs(summary2.wasted) }) : null
|
|
2097
|
+
]
|
|
2098
|
+
)
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
table.append(list);
|
|
2102
|
+
}
|
|
2103
|
+
function deltaCell(n, suffix = "", decimals) {
|
|
2104
|
+
const cls = n < 0 ? "good" : n > 0 ? "bad" : "";
|
|
2105
|
+
const value = decimals !== void 0 ? n.toFixed(decimals) : Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
2106
|
+
return el("td", { class: "num delta " + cls, text: n === 0 ? "\xB10" : `${n > 0 ? "+" : ""}${value}${suffix}` });
|
|
2107
|
+
}
|
|
2108
|
+
function renderSessionDetails() {
|
|
2109
|
+
const session = state.sessions.find((s) => s.id === state.selectedSession);
|
|
2110
|
+
if (!session) {
|
|
2111
|
+
details.append(el("div", { class: "empty", text: "Select a session." }));
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const live = session === state.recording;
|
|
2115
|
+
const summary2 = live ? sessionSummary(session) : session;
|
|
2116
|
+
const nameInput = el("input", { type: "text", class: "session-name", value: session.name, title: "Rename" });
|
|
2117
|
+
nameInput.addEventListener("change", () => {
|
|
2118
|
+
session.name = nameInput.value.trim() || session.name;
|
|
2119
|
+
nameInput.value = session.name;
|
|
2120
|
+
persistSessions();
|
|
2121
|
+
if (state.view === "sessions") renderLeft();
|
|
2122
|
+
});
|
|
2123
|
+
details.append(
|
|
2124
|
+
el("div", { class: "details-header" }, [
|
|
2125
|
+
nameInput,
|
|
2126
|
+
el("span", { class: "meta", text: `${live ? "recording \xB7 " : ""}${fmtDuration(summary2)} \xB7 ${plural(summary2.total, "render")}, ${summary2.avoidable} avoidable${summary2.wasted ? ", " + fmtMs(summary2.wasted) + " wasted" : ""}` }),
|
|
2127
|
+
live ? el("button", { class: "ib", onclick: () => stopRecording() }, "\u23F9 Stop") : null
|
|
2128
|
+
])
|
|
2129
|
+
);
|
|
2130
|
+
const body = el("div", { class: "details-body" });
|
|
2131
|
+
details.append(body);
|
|
2132
|
+
const others = state.sessions.filter((s) => s !== session && s.endedAt);
|
|
2133
|
+
const compareSec = el("div", { class: "section compare" }, [el("h3", { text: "Compare" })]);
|
|
2134
|
+
if (!others.length) {
|
|
2135
|
+
compareSec.append(el("div", { class: "meta", text: "Record a second session (after a fix) to compare against this one." }));
|
|
2136
|
+
} else {
|
|
2137
|
+
const select2 = el("select", { class: "compare-select" });
|
|
2138
|
+
select2.append(el("option", { value: "", text: "Compare with\u2026" }));
|
|
2139
|
+
for (const o of others) select2.append(el("option", { value: o.id, text: o.name }));
|
|
2140
|
+
const baseline = state.compareWith && others.some((o) => o.id === state.compareWith) ? state.compareWith : others[others.length - 1].id;
|
|
2141
|
+
select2.value = baseline;
|
|
2142
|
+
select2.addEventListener("change", () => {
|
|
2143
|
+
state.compareWith = select2.value || null;
|
|
2144
|
+
renderDetails();
|
|
2145
|
+
});
|
|
2146
|
+
compareSec.append(el("div", { class: "meta" }, ["Baseline: ", select2, " \u2192 this session"]));
|
|
2147
|
+
const before = others.find((o) => o.id === baseline);
|
|
2148
|
+
const cmp = compareSessions(before, summary2);
|
|
2149
|
+
const t = el("table", { class: "grid compare-grid" });
|
|
2150
|
+
t.append(el("thead", null, el("tr", null, [el("th", { text: "Avoidable re-renders" }), el("th", { class: "num", text: before.name }), el("th", { class: "num", text: summary2.name }), el("th", { class: "num", text: "\u0394" })])));
|
|
2151
|
+
const tb = el("tbody");
|
|
2152
|
+
const totalRow = el("tr", { class: "total" }, [el("td", { text: "All components" }), el("td", { class: "num", text: String(cmp.avoidable.before) }), el("td", { class: "num", text: String(cmp.avoidable.after) })]);
|
|
2153
|
+
totalRow.append(deltaCell(cmp.avoidable.delta));
|
|
2154
|
+
tb.append(totalRow);
|
|
2155
|
+
if (cmp.wasted.before || cmp.wasted.after) {
|
|
2156
|
+
const w = el("tr", { class: "total" }, [el("td", { text: "Wasted time" }), el("td", { class: "num", text: fmtMs(cmp.wasted.before) }), el("td", { class: "num", text: fmtMs(cmp.wasted.after) })]);
|
|
2157
|
+
w.append(deltaCell(cmp.wasted.delta, " ms", 1));
|
|
2158
|
+
tb.append(w);
|
|
2159
|
+
}
|
|
2160
|
+
for (const row of cmp.rows) {
|
|
2161
|
+
const tr = el("tr", null, [el("td", { class: "c" }, el("span", { class: "name", text: row.component })), el("td", { class: "num", text: String(row.before) }), el("td", { class: "num", text: String(row.after) })]);
|
|
2162
|
+
tr.append(deltaCell(row.delta));
|
|
2163
|
+
tb.append(tr);
|
|
2164
|
+
}
|
|
2165
|
+
t.append(tb);
|
|
2166
|
+
compareSec.append(t);
|
|
2167
|
+
if (cmp.resolvedFixes.length) compareSec.append(el("div", { class: "meta" }, [el("b", { text: "No longer needed: " }), cmp.resolvedFixes.map((f) => f.label).join(" \xB7 ")]));
|
|
2168
|
+
if (cmp.newFixes.length) compareSec.append(el("div", { class: "meta" }, [el("b", { text: "New: " }), cmp.newFixes.map((f) => f.label).join(" \xB7 ")]));
|
|
2169
|
+
}
|
|
2170
|
+
body.append(compareSec);
|
|
2171
|
+
const comps = Object.entries(summary2.byComponent).sort((a, b) => b[1].avoidable - a[1].avoidable || b[1].total - a[1].total);
|
|
2172
|
+
if (comps.length) {
|
|
2173
|
+
const t = el("table", { class: "grid" });
|
|
2174
|
+
t.append(el("thead", null, el("tr", null, [el("th", { text: "Component" }), el("th", { class: "num", text: "Avoidable" }), el("th", { class: "num", text: "Total" }), el("th", { class: "num", text: "Wasted" })])));
|
|
2175
|
+
const tb = el("tbody");
|
|
2176
|
+
for (const [name, c] of comps.slice(0, 50)) {
|
|
2177
|
+
tb.append(
|
|
2178
|
+
el("tr", { onclick: () => panelApi.select(name) }, [
|
|
2179
|
+
el("td", { class: "c" }, el("span", { class: "name", text: name })),
|
|
2180
|
+
el("td", { class: "num" }, c.avoidable ? el("span", { class: "badge avoid", text: String(c.avoidable) }) : "0"),
|
|
2181
|
+
el("td", { class: "num", text: String(c.total) }),
|
|
2182
|
+
el("td", { class: "num", text: c.wasted ? fmtMs(c.wasted) : "" })
|
|
2183
|
+
])
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
t.append(tb);
|
|
2187
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Components in this session" }), t]));
|
|
2188
|
+
}
|
|
2189
|
+
if (summary2.fixes.length) {
|
|
2190
|
+
body.append(el("div", { class: "section" }, [el("h3", { text: "Fixes suggested" }), el("ol", { class: "fix-list" }, summary2.fixes.slice(0, 10).map((f) => el("li", null, [el("span", { class: "badge avoid", text: String(f.count) }), " ", el("span", { class: "mono", text: f.label })])))]));
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
const itemEls = /* @__PURE__ */ new WeakMap();
|
|
2194
|
+
const streamItems = virtualList(streamList, ITEM_H, (r) => {
|
|
2195
|
+
let li = itemEls.get(r);
|
|
2196
|
+
if (!li) {
|
|
2197
|
+
li = el("div", { class: "stream-item", onclick: () => select(nodeOfReport(r), r) }, [
|
|
2198
|
+
el("span", { class: "t", text: fmtTime(r.receivedAt) }),
|
|
2199
|
+
el("span", { class: "c", text: r.component }),
|
|
2200
|
+
el("span", { class: "v " + (r.avoidable ? "avoid" : "ok"), text: r.avoidable ? "avoidable" : r.trigger }),
|
|
2201
|
+
el("span", { class: "s", text: summarize(r) })
|
|
2202
|
+
]);
|
|
2203
|
+
itemEls.set(r, li);
|
|
2204
|
+
}
|
|
2205
|
+
return li;
|
|
2206
|
+
});
|
|
2207
|
+
let streamShown = [];
|
|
2208
|
+
function renderStream(batch) {
|
|
2209
|
+
if (batch) {
|
|
2210
|
+
const fresh = batch.filter(passes).reverse();
|
|
2211
|
+
if (fresh.length) streamShown = fresh.concat(streamShown);
|
|
2212
|
+
if (streamShown.length > MAX_REPORTS) streamShown.length = MAX_REPORTS;
|
|
2213
|
+
} else {
|
|
2214
|
+
streamShown = filteredReports().reverse();
|
|
2215
|
+
}
|
|
2216
|
+
streamCount.textContent = plural(streamShown.length, "report");
|
|
2217
|
+
streamItems.setItems(streamShown);
|
|
2218
|
+
}
|
|
2219
|
+
function exportJson() {
|
|
2220
|
+
const data = {
|
|
2221
|
+
rerenderLens: true,
|
|
2222
|
+
version: PROTOCOL,
|
|
2223
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2224
|
+
origin: state.origin,
|
|
2225
|
+
reports: state.reports,
|
|
2226
|
+
sessions: state.sessions.filter((s) => s.endedAt).map(sessionSummary)
|
|
2227
|
+
};
|
|
2228
|
+
const text = JSON.stringify(data, null, 2);
|
|
2229
|
+
const name = `rerender-lens-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`;
|
|
2230
|
+
try {
|
|
2231
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
2232
|
+
const url = URL.createObjectURL(blob);
|
|
2233
|
+
const a = el("a", { href: url, download: name });
|
|
2234
|
+
document.body.append(a);
|
|
2235
|
+
a.click();
|
|
2236
|
+
a.remove();
|
|
2237
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
2238
|
+
} catch {
|
|
2239
|
+
copyText(text);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
function importData(data) {
|
|
2243
|
+
const reports = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.reports) ? data.reports : null;
|
|
2244
|
+
if (!reports) throw new Error("not a rerender-lens export");
|
|
2245
|
+
clearAll();
|
|
2246
|
+
if (isRecord(data) && Array.isArray(data.sessions)) restoreSessions(data.sessions);
|
|
2247
|
+
let n = 0;
|
|
2248
|
+
for (const p of reports) {
|
|
2249
|
+
const r = normalizeReport(p);
|
|
2250
|
+
if (r) {
|
|
2251
|
+
enqueue(r);
|
|
2252
|
+
n++;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
flush();
|
|
2256
|
+
toast(`Imported ${plural(n, "report")}`);
|
|
2257
|
+
return n;
|
|
2258
|
+
}
|
|
2259
|
+
function importFile(file) {
|
|
2260
|
+
const reader = new FileReader();
|
|
2261
|
+
reader.onload = () => {
|
|
2262
|
+
try {
|
|
2263
|
+
importData(JSON.parse(String(reader.result)));
|
|
2264
|
+
} catch (e) {
|
|
2265
|
+
toast(`Import failed: ${e.message}`);
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
reader.readAsText(file);
|
|
2269
|
+
}
|
|
2270
|
+
function renderStatus() {
|
|
2271
|
+
const lib = state.library;
|
|
2272
|
+
let text;
|
|
2273
|
+
let cls = "none";
|
|
2274
|
+
let title = "";
|
|
2275
|
+
if (lib) {
|
|
2276
|
+
const react = lib.react && lib.react[0];
|
|
2277
|
+
text = `connected \xB7 lib ${lib.library || "?"}${react && react.version ? ` \xB7 React ${react.version}` : ""}${lib.production ? " (prod)" : ""}`;
|
|
2278
|
+
cls = "connected";
|
|
2279
|
+
title = state.relay ? "live via content script" : "polling the page";
|
|
2280
|
+
if (lib.overhead) title += ` \xB7 library overhead ${lib.overhead.totalMs.toFixed(1)} ms over ${plural(lib.commits ?? 0, "commit")}, worst ${lib.overhead.maxCommitMs.toFixed(1)} ms`;
|
|
2281
|
+
if (lib.truncated) title += ` \xB7 ${plural(lib.truncated, "report")} skipped by the per-commit cap`;
|
|
2282
|
+
} else if (state.relay || state.polling) {
|
|
2283
|
+
text = "no library in page";
|
|
2284
|
+
cls = "partial";
|
|
2285
|
+
title = "rerender-lens is not running in this page";
|
|
2286
|
+
} else {
|
|
2287
|
+
text = "no page";
|
|
2288
|
+
}
|
|
2289
|
+
status.className = "status " + cls;
|
|
2290
|
+
status.title = title;
|
|
2291
|
+
status.querySelector(".status-text").textContent = text;
|
|
2292
|
+
tabChip.hidden = !state.tabLabel;
|
|
2293
|
+
tabChip.textContent = state.tabLabel || "";
|
|
2294
|
+
banner.textContent = "";
|
|
2295
|
+
const warnings = [];
|
|
2296
|
+
if (lib && typeof lib.protocol === "number" && lib.protocol > PROTOCOL) warnings.push(`The page runs a newer rerender-lens (protocol ${lib.protocol}) than this panel (${PROTOCOL}). Update the extension.`);
|
|
2297
|
+
if (lib && lib.production) warnings.push("Production React build detected: component names may be minified and hooks are unlabeled. Use a development build.");
|
|
2298
|
+
if (lib && lib.injected && lib.source === "page")
|
|
2299
|
+
warnings.push(`The page runs its own rerender-lens ${lib.library || ""}; the copy injected by the extension stepped aside. Turn injection off for this origin in Settings to avoid loading the library twice.`);
|
|
2300
|
+
if (lib && lib.enabled === false) warnings.push("rerender-lens is present but disabled in this page.");
|
|
2301
|
+
if (lib && lib.truncated) warnings.push(`${plural(lib.truncated, "report")} skipped: a commit re-rendered more tracked components than the per-commit cap (200) or took over its time budget. Narrow "include" in Settings, or fix the top offenders first.`);
|
|
2302
|
+
banner.hidden = warnings.length === 0;
|
|
2303
|
+
for (const w of warnings) banner.append(el("div", { text: w }));
|
|
2304
|
+
}
|
|
2305
|
+
function setRelay(on) {
|
|
2306
|
+
state.relay = on;
|
|
2307
|
+
if (!on) state.library = null;
|
|
2308
|
+
renderStatus();
|
|
2309
|
+
}
|
|
2310
|
+
function setLibrary(info) {
|
|
2311
|
+
state.library = info;
|
|
2312
|
+
renderStatus();
|
|
2313
|
+
if (state.settingsOpen) void renderSettings();
|
|
2314
|
+
if (state.flashOn) transport.flashAvoidable?.(true);
|
|
2315
|
+
}
|
|
2316
|
+
function toggleSettings(open) {
|
|
2317
|
+
state.settingsOpen = open === void 0 ? !state.settingsOpen : open;
|
|
2318
|
+
settings.hidden = !state.settingsOpen;
|
|
2319
|
+
settingsBtn.classList.toggle("active", state.settingsOpen);
|
|
2320
|
+
settingsBtn.setAttribute("aria-expanded", String(state.settingsOpen));
|
|
2321
|
+
if (state.settingsOpen) void renderSettings().then(() => settings.querySelector("input, button")?.focus());
|
|
2322
|
+
else settingsBtn.focus();
|
|
2323
|
+
}
|
|
2324
|
+
function optionRow(label, key, current, onchange) {
|
|
2325
|
+
const input = el("input", { type: "checkbox" });
|
|
2326
|
+
input.checked = !!current[key];
|
|
2327
|
+
input.addEventListener("change", () => onchange({ [key]: input.checked }));
|
|
2328
|
+
return el("label", { class: "opt" }, [input, label]);
|
|
2329
|
+
}
|
|
2330
|
+
async function renderSettings() {
|
|
2331
|
+
settings.textContent = "";
|
|
2332
|
+
settings.append(el("div", { class: "drawer-header" }, [el("b", { text: "Settings" }), el("button", { onclick: () => toggleSettings(false) }, "\u2715")]));
|
|
2333
|
+
const body = el("div", { class: "drawer-body" });
|
|
2334
|
+
settings.append(body);
|
|
2335
|
+
if (transport.originStatus) {
|
|
2336
|
+
const site = el("div", { class: "section" }, [el("h3", { text: "This site" }), el("div", { class: "meta", text: state.origin || "" })]);
|
|
2337
|
+
body.append(site);
|
|
2338
|
+
try {
|
|
2339
|
+
const st = await transport.originStatus();
|
|
2340
|
+
if (st) {
|
|
2341
|
+
const enabled = el("input", { type: "checkbox" });
|
|
2342
|
+
enabled.checked = st.enabled;
|
|
2343
|
+
enabled.disabled = st.builtIn;
|
|
2344
|
+
const inject = el("input", { type: "checkbox" });
|
|
2345
|
+
inject.checked = st.inject;
|
|
2346
|
+
inject.disabled = !st.enabled;
|
|
2347
|
+
const defer = el("input", { type: "checkbox" });
|
|
2348
|
+
defer.checked = !!st.deferHook;
|
|
2349
|
+
defer.disabled = !st.inject;
|
|
2350
|
+
const msg = el("div", { class: "meta" });
|
|
2351
|
+
const apply = async () => {
|
|
2352
|
+
try {
|
|
2353
|
+
if (enabled.checked && !st.permitted && transport.requestPermission) {
|
|
2354
|
+
const ok = await transport.requestPermission();
|
|
2355
|
+
if (!ok) {
|
|
2356
|
+
msg.textContent = "Permission not granted. You can also enable the site from the toolbar icon.";
|
|
2357
|
+
enabled.checked = false;
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
await transport.setOrigin?.({ enabled: enabled.checked, inject: enabled.checked && inject.checked, deferHook: inject.checked && defer.checked });
|
|
2362
|
+
void renderSettings();
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
msg.textContent = String(e.message || e);
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
enabled.addEventListener("change", () => {
|
|
2368
|
+
if (!enabled.checked) inject.checked = false;
|
|
2369
|
+
void apply();
|
|
2370
|
+
});
|
|
2371
|
+
inject.addEventListener("change", () => void apply());
|
|
2372
|
+
defer.addEventListener("change", () => void apply());
|
|
2373
|
+
site.append(
|
|
2374
|
+
el("label", { class: "opt" }, [enabled, st.builtIn ? "Enabled (local development host)" : "Enable on this site"]),
|
|
2375
|
+
el("label", { class: "opt" }, [inject, "Inject the library into the page (no app code needed)"]),
|
|
2376
|
+
el("label", { class: "opt", title: "Only needed when React DevTools is installed and its Components tab comes up empty" }, [defer, "Let React DevTools create the hook (if both are installed)"]),
|
|
2377
|
+
el("div", { class: "meta", text: st.inject ? "Injection is on. Reload the page after changing it." : "Without injection the page must call init({ notifier: createDevtoolsNotifier() })." }),
|
|
2378
|
+
msg
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
} catch (e) {
|
|
2382
|
+
site.append(el("div", { class: "meta", text: String(e.message || e) }));
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
const flash = el("input", { type: "checkbox" });
|
|
2386
|
+
flash.checked = state.flashOn;
|
|
2387
|
+
flash.addEventListener("change", () => {
|
|
2388
|
+
state.flashOn = flash.checked;
|
|
2389
|
+
transport.flashAvoidable?.(state.flashOn);
|
|
2390
|
+
persist();
|
|
2391
|
+
});
|
|
2392
|
+
body.append(
|
|
2393
|
+
el("div", { class: "section" }, [
|
|
2394
|
+
el("h3", { text: "Panel" }),
|
|
2395
|
+
el("label", { class: "opt" }, [flash, "Flash avoidable re-renders in the page"]),
|
|
2396
|
+
el("div", { class: "meta", text: "Shortcuts: / search, f fix tab, Esc clear highlight, arrows in the tree." })
|
|
2397
|
+
])
|
|
2398
|
+
);
|
|
2399
|
+
const lib = state.library;
|
|
2400
|
+
const sec = el("div", { class: "section" }, [el("h3", { text: "Library options" })]);
|
|
2401
|
+
body.append(sec);
|
|
2402
|
+
if (!lib || !transport.configure) {
|
|
2403
|
+
sec.append(el("div", { class: "meta", text: "Connect to a page running rerender-lens to change its options." }));
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const current = Object.assign({}, lib.options || {});
|
|
2407
|
+
const applyOptions = async (patch) => {
|
|
2408
|
+
Object.assign(current, patch);
|
|
2409
|
+
try {
|
|
2410
|
+
const applied = await transport.configure(patch);
|
|
2411
|
+
if (applied && state.library) state.library.options = applied;
|
|
2412
|
+
if (transport.storage && state.library) transport.storage.set("settings", Object.assign({}, state.library.options));
|
|
2413
|
+
toast("Applied");
|
|
2414
|
+
} catch (e) {
|
|
2415
|
+
toast(`Failed: ${e.message}`);
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
sec.append(
|
|
2419
|
+
optionRow("Track every React.memo / PureComponent", "trackAllMemoized", current, applyOptions),
|
|
2420
|
+
optionRow("Track every component (noisy)", "trackAllComponents", current, applyOptions),
|
|
2421
|
+
optionRow("Diff hook state and contexts", "trackHooks", { trackHooks: current.trackHooks !== false }, applyOptions),
|
|
2422
|
+
optionRow("Include current hooks, state and contexts in every report", "includeState", { includeState: current.includeState !== false }, applyOptions),
|
|
2423
|
+
optionRow("Resolve custom hook names (re-runs each component type once)", "resolveHookNames", current, applyOptions),
|
|
2424
|
+
optionRow("Ignore Fast Refresh commits", "ignoreHotReload", { ignoreHotReload: current.ignoreHotReload !== false }, applyOptions),
|
|
2425
|
+
optionRow("Print to the page console", "silent", { silent: !current.silent }, (p) => applyOptions({ silent: !p.silent })),
|
|
2426
|
+
optionRow("Print genuine re-renders too (logAll)", "logAll", current, applyOptions)
|
|
2427
|
+
);
|
|
2428
|
+
const listInput = (label, key) => {
|
|
2429
|
+
const input = el("input", { type: "text", placeholder: "Name, /regex/, ...", value: (current[key] || []).join(", ") });
|
|
2430
|
+
input.addEventListener("change", () => void applyOptions({ [key]: input.value.split(",").map((s) => s.trim()).filter(Boolean) }));
|
|
2431
|
+
return el("label", { class: "opt col" }, [label, input]);
|
|
2432
|
+
};
|
|
2433
|
+
sec.append(listInput("Include (display names)", "include"), listInput("Exclude", "exclude"));
|
|
2434
|
+
const max = el("input", { type: "number", min: "0", value: String(current.maxReportsPerComponent || 0) });
|
|
2435
|
+
max.addEventListener("change", () => void applyOptions({ maxReportsPerComponent: Math.max(0, Number(max.value) || 0) }));
|
|
2436
|
+
sec.append(el("label", { class: "opt col" }, ["Stop printing a component after N reports (0 = never)", max]));
|
|
2437
|
+
}
|
|
2438
|
+
function handle(message) {
|
|
2439
|
+
if (!isRecord(message)) return;
|
|
2440
|
+
if (transport.origin && transport.origin !== state.origin) state.origin = transport.origin;
|
|
2441
|
+
switch (message.type) {
|
|
2442
|
+
case "connected":
|
|
2443
|
+
setRelay(true);
|
|
2444
|
+
break;
|
|
2445
|
+
case "disconnected":
|
|
2446
|
+
setRelay(false);
|
|
2447
|
+
break;
|
|
2448
|
+
case "polling":
|
|
2449
|
+
state.polling = !!message.on;
|
|
2450
|
+
renderStatus();
|
|
2451
|
+
break;
|
|
2452
|
+
case "tab-label":
|
|
2453
|
+
state.tabLabel = typeof message.payload === "string" ? message.payload : null;
|
|
2454
|
+
renderStatus();
|
|
2455
|
+
break;
|
|
2456
|
+
case "tab":
|
|
2457
|
+
state.tabLabel = typeof message.payload === "string" ? message.payload : null;
|
|
2458
|
+
state.origin = transport.origin || null;
|
|
2459
|
+
clearAll();
|
|
2460
|
+
state.library = null;
|
|
2461
|
+
renderStatus();
|
|
2462
|
+
break;
|
|
2463
|
+
case "hello":
|
|
2464
|
+
if (isRecord(message.payload)) setLibrary(message.payload);
|
|
2465
|
+
break;
|
|
2466
|
+
case "clear":
|
|
2467
|
+
case "navigated":
|
|
2468
|
+
clearAll();
|
|
2469
|
+
if (message.type === "navigated") {
|
|
2470
|
+
state.library = null;
|
|
2471
|
+
renderStatus();
|
|
2472
|
+
}
|
|
2473
|
+
break;
|
|
2474
|
+
case "report": {
|
|
2475
|
+
if (state.paused) break;
|
|
2476
|
+
const r = normalizeReport(message.payload);
|
|
2477
|
+
if (r) enqueue(r);
|
|
2478
|
+
break;
|
|
2479
|
+
}
|
|
2480
|
+
case "batch":
|
|
2481
|
+
if (Array.isArray(message.items)) for (const item of message.items) handle(item);
|
|
2482
|
+
break;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
const panelApi = {
|
|
2486
|
+
state,
|
|
2487
|
+
handle,
|
|
2488
|
+
flush,
|
|
2489
|
+
clearAll,
|
|
2490
|
+
importData,
|
|
2491
|
+
select: (name) => {
|
|
2492
|
+
for (const n of state.nodesByKey.values()) if (n.name === name) return select(n);
|
|
2493
|
+
},
|
|
2494
|
+
setView,
|
|
2495
|
+
openSettings: () => toggleSettings(true),
|
|
2496
|
+
startRecording,
|
|
2497
|
+
stopRecording
|
|
2498
|
+
};
|
|
2499
|
+
if (options.theme === "dark") document.documentElement.classList.add("theme-dark");
|
|
2500
|
+
state.origin = transport.origin || null;
|
|
2501
|
+
setView(state.view);
|
|
2502
|
+
renderDetails();
|
|
2503
|
+
renderStream();
|
|
2504
|
+
renderSummary();
|
|
2505
|
+
renderStatus();
|
|
2506
|
+
measure();
|
|
2507
|
+
if (transport.storage) {
|
|
2508
|
+
Promise.resolve(transport.storage.get("panel")).then(restore, () => {
|
|
2509
|
+
});
|
|
2510
|
+
Promise.resolve(transport.storage.get("sessions")).then(restoreSessions, () => {
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
transport.subscribe(handle);
|
|
2514
|
+
return panelApi;
|
|
2515
|
+
}
|
|
2516
|
+
var systemTheme = () => typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
2517
|
+
var fetchSource = (url) => fetch(url).then((res) => res.ok ? res.text() : null).catch(() => null);
|
|
2518
|
+
function localStorageAdapter(prefix) {
|
|
2519
|
+
const mem = (key) => `${prefix}:${key}`;
|
|
2520
|
+
return {
|
|
2521
|
+
get: (key) => {
|
|
2522
|
+
try {
|
|
2523
|
+
const raw = localStorage.getItem(mem(key));
|
|
2524
|
+
return raw ? JSON.parse(raw) : void 0;
|
|
2525
|
+
} catch {
|
|
2526
|
+
return void 0;
|
|
2527
|
+
}
|
|
2528
|
+
},
|
|
2529
|
+
set: (key, value) => {
|
|
2530
|
+
try {
|
|
2531
|
+
localStorage.setItem(mem(key), JSON.stringify(value));
|
|
2532
|
+
} catch {
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
function commandBridge(send, timeoutMs) {
|
|
2538
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2539
|
+
const bridge = (cmd, arg) => new Promise((resolve, reject) => {
|
|
2540
|
+
const id = Math.random().toString(36).slice(2);
|
|
2541
|
+
const fail = () => {
|
|
2542
|
+
clearTimeout(timer);
|
|
2543
|
+
pending.delete(id);
|
|
2544
|
+
resolve(null);
|
|
2545
|
+
};
|
|
2546
|
+
const timer = setTimeout(fail, timeoutMs);
|
|
2547
|
+
pending.set(id, { resolve: (v) => v instanceof Error ? reject(v) : resolve(v), timer });
|
|
2548
|
+
try {
|
|
2549
|
+
void Promise.resolve(send({ __rerenderLensCmd: true, id, cmd, arg })).catch(fail);
|
|
2550
|
+
} catch {
|
|
2551
|
+
fail();
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
const reply = (m) => {
|
|
2555
|
+
if (m.__rerenderLensReply !== true || typeof m.id !== "string") return false;
|
|
2556
|
+
const p = pending.get(m.id);
|
|
2557
|
+
if (p) {
|
|
2558
|
+
pending.delete(m.id);
|
|
2559
|
+
clearTimeout(p.timer);
|
|
2560
|
+
p.resolve(typeof m.error === "string" ? new Error(m.error) : m.result);
|
|
2561
|
+
}
|
|
2562
|
+
return true;
|
|
2563
|
+
};
|
|
2564
|
+
return { bridge, reply };
|
|
2565
|
+
}
|
|
2566
|
+
function createRelayTransport(io) {
|
|
2567
|
+
let listener = null;
|
|
2568
|
+
let relayConnected = false;
|
|
2569
|
+
let pollTimer = null;
|
|
2570
|
+
let since = 0;
|
|
2571
|
+
let origin = null;
|
|
2572
|
+
let panelPort = null;
|
|
2573
|
+
let currentTab = io.tabId();
|
|
2574
|
+
const emit = (m) => {
|
|
2575
|
+
if (listener) listener(m);
|
|
2576
|
+
};
|
|
2577
|
+
const send = (message) => new Promise(
|
|
2578
|
+
(resolve, reject) => chrome.runtime.sendMessage(message, (res) => {
|
|
2579
|
+
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
|
|
2580
|
+
else if (!res || !res.ok) reject(new Error(res && res.error || "no response"));
|
|
2581
|
+
else resolve(res.result);
|
|
2582
|
+
})
|
|
2583
|
+
);
|
|
2584
|
+
async function resolveOrigin() {
|
|
2585
|
+
origin = await io.origin().catch(() => null);
|
|
2586
|
+
transport.origin = origin;
|
|
2587
|
+
}
|
|
2588
|
+
async function syncWithPage() {
|
|
2589
|
+
try {
|
|
2590
|
+
const info = await io.bridge("info");
|
|
2591
|
+
if (info) {
|
|
2592
|
+
emit({ type: "hello", version: info.protocol, payload: info });
|
|
2593
|
+
return true;
|
|
2594
|
+
}
|
|
2595
|
+
} catch {
|
|
2596
|
+
}
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
let syncGen = 0;
|
|
2600
|
+
let syncTimer = null;
|
|
2601
|
+
let disposed = false;
|
|
2602
|
+
function cancelSync() {
|
|
2603
|
+
syncGen++;
|
|
2604
|
+
if (syncTimer) clearTimeout(syncTimer);
|
|
2605
|
+
syncTimer = null;
|
|
2606
|
+
}
|
|
2607
|
+
function syncWithRetry(onReady, initialDelay = 0) {
|
|
2608
|
+
cancelSync();
|
|
2609
|
+
const gen = syncGen;
|
|
2610
|
+
let delay = SYNC_RETRY_MIN;
|
|
2611
|
+
let tries = 0;
|
|
2612
|
+
const attempt = async () => {
|
|
2613
|
+
syncTimer = null;
|
|
2614
|
+
if (gen !== syncGen || disposed) return;
|
|
2615
|
+
const ok = await syncWithPage();
|
|
2616
|
+
if (gen !== syncGen || disposed) return;
|
|
2617
|
+
if (ok) {
|
|
2618
|
+
onReady();
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (++tries >= SYNC_RETRIES) return;
|
|
2622
|
+
syncTimer = setTimeout(() => void attempt(), delay);
|
|
2623
|
+
delay = Math.min(delay * 2, SYNC_RETRY_MAX);
|
|
2624
|
+
};
|
|
2625
|
+
if (initialDelay > 0) syncTimer = setTimeout(() => void attempt(), initialDelay);
|
|
2626
|
+
else void attempt();
|
|
2627
|
+
}
|
|
2628
|
+
let droppedSeen = false;
|
|
2629
|
+
function resetSince() {
|
|
2630
|
+
since = 0;
|
|
2631
|
+
droppedSeen = false;
|
|
2632
|
+
}
|
|
2633
|
+
async function pollOnce() {
|
|
2634
|
+
try {
|
|
2635
|
+
const res = await io.bridge("pull", since);
|
|
2636
|
+
if (!res) return;
|
|
2637
|
+
if (res.dropped && !droppedSeen) {
|
|
2638
|
+
droppedSeen = true;
|
|
2639
|
+
emit({ type: "clear" });
|
|
2640
|
+
}
|
|
2641
|
+
for (const p of res.reports) emit({ type: "report", payload: p });
|
|
2642
|
+
since = res.seq;
|
|
2643
|
+
} catch {
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
function setPolling(on) {
|
|
2647
|
+
if (on && !pollTimer) {
|
|
2648
|
+
pollTimer = setInterval(() => void pollOnce(), 500);
|
|
2649
|
+
emit({ type: "polling", on: true });
|
|
2650
|
+
} else if (!on && pollTimer) {
|
|
2651
|
+
clearInterval(pollTimer);
|
|
2652
|
+
pollTimer = null;
|
|
2653
|
+
emit({ type: "polling", on: false });
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
function attachToPage(initialDelay = 0) {
|
|
2657
|
+
syncWithRetry(() => {
|
|
2658
|
+
if (relayConnected) io.bridge("replay").catch(() => {
|
|
2659
|
+
});
|
|
2660
|
+
else {
|
|
2661
|
+
const gen = syncGen;
|
|
2662
|
+
void io.bridge("pull", 0).catch(() => null).then((res) => {
|
|
2663
|
+
if (gen !== syncGen || disposed) return;
|
|
2664
|
+
if (res) {
|
|
2665
|
+
for (const p of res.reports) emit({ type: "report", payload: p });
|
|
2666
|
+
since = res.seq;
|
|
2667
|
+
} else io.bridge("replay").catch(() => {
|
|
2668
|
+
});
|
|
2669
|
+
setPolling(true);
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
}, initialDelay);
|
|
2673
|
+
}
|
|
2674
|
+
function connect() {
|
|
2675
|
+
if (panelPort) {
|
|
2676
|
+
try {
|
|
2677
|
+
panelPort.disconnect();
|
|
2678
|
+
} catch {
|
|
2679
|
+
}
|
|
2680
|
+
panelPort = null;
|
|
2681
|
+
}
|
|
2682
|
+
if (currentTab === null) return;
|
|
2683
|
+
const port = chrome.runtime.connect({ name: "rerender-lens-panel" });
|
|
2684
|
+
panelPort = port;
|
|
2685
|
+
port.postMessage({ type: "init", tabId: currentTab });
|
|
2686
|
+
port.onMessage.addListener((m) => {
|
|
2687
|
+
if (!m || panelPort !== port) return;
|
|
2688
|
+
if (m.type === "connected") {
|
|
2689
|
+
relayConnected = true;
|
|
2690
|
+
setPolling(false);
|
|
2691
|
+
} else if (m.type === "disconnected") {
|
|
2692
|
+
relayConnected = false;
|
|
2693
|
+
syncWithRetry(() => setPolling(true));
|
|
2694
|
+
}
|
|
2695
|
+
emit(m);
|
|
2696
|
+
});
|
|
2697
|
+
port.onDisconnect.addListener(() => {
|
|
2698
|
+
if (panelPort !== port) return;
|
|
2699
|
+
panelPort = null;
|
|
2700
|
+
reconnectTimer = setTimeout(() => {
|
|
2701
|
+
reconnectTimer = null;
|
|
2702
|
+
if (!disposed) connect();
|
|
2703
|
+
}, 1e3);
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
let reconnectTimer = null;
|
|
2707
|
+
const transport = {
|
|
2708
|
+
origin,
|
|
2709
|
+
tabLabel: io.tabLabel ?? null,
|
|
2710
|
+
subscribe(fn) {
|
|
2711
|
+
listener = fn;
|
|
2712
|
+
connect();
|
|
2713
|
+
io.onNavigated(() => {
|
|
2714
|
+
if (disposed) return;
|
|
2715
|
+
resetSince();
|
|
2716
|
+
setPolling(false);
|
|
2717
|
+
cancelSync();
|
|
2718
|
+
fn({ type: "navigated" });
|
|
2719
|
+
void resolveOrigin().then(() => {
|
|
2720
|
+
if (!disposed) attachToPage(NAVIGATION_SETTLE);
|
|
2721
|
+
});
|
|
2722
|
+
});
|
|
2723
|
+
io.onTabChange?.((label) => {
|
|
2724
|
+
if (disposed) return;
|
|
2725
|
+
transport.tabLabel = label;
|
|
2726
|
+
const next = io.tabId();
|
|
2727
|
+
if (next === currentTab) {
|
|
2728
|
+
fn({ type: "tab-label", payload: label });
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
resetSince();
|
|
2732
|
+
relayConnected = false;
|
|
2733
|
+
setPolling(false);
|
|
2734
|
+
cancelSync();
|
|
2735
|
+
currentTab = next;
|
|
2736
|
+
void resolveOrigin().then(() => {
|
|
2737
|
+
if (disposed) return;
|
|
2738
|
+
fn({ type: "tab", payload: label });
|
|
2739
|
+
connect();
|
|
2740
|
+
attachToPage();
|
|
2741
|
+
});
|
|
2742
|
+
});
|
|
2743
|
+
void resolveOrigin().then(() => {
|
|
2744
|
+
if (!disposed) attachToPage();
|
|
2745
|
+
});
|
|
2746
|
+
},
|
|
2747
|
+
replay() {
|
|
2748
|
+
if (relayConnected) io.bridge("replay").catch(() => {
|
|
2749
|
+
});
|
|
2750
|
+
else {
|
|
2751
|
+
resetSince();
|
|
2752
|
+
emit({ type: "clear" });
|
|
2753
|
+
void syncWithPage().then(() => pollOnce());
|
|
2754
|
+
}
|
|
2755
|
+
},
|
|
2756
|
+
clear() {
|
|
2757
|
+
io.bridge("clear").catch(() => {
|
|
2758
|
+
});
|
|
2759
|
+
resetSince();
|
|
2760
|
+
},
|
|
2761
|
+
dispose() {
|
|
2762
|
+
disposed = true;
|
|
2763
|
+
cancelSync();
|
|
2764
|
+
setPolling(false);
|
|
2765
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
2766
|
+
reconnectTimer = null;
|
|
2767
|
+
if (panelPort) {
|
|
2768
|
+
try {
|
|
2769
|
+
panelPort.disconnect();
|
|
2770
|
+
} catch {
|
|
2771
|
+
}
|
|
2772
|
+
panelPort = null;
|
|
2773
|
+
}
|
|
2774
|
+
listener = null;
|
|
2775
|
+
},
|
|
2776
|
+
configure: (options) => io.bridge("configure", options).then((r) => r ?? void 0),
|
|
2777
|
+
highlight: (id) => io.bridge("highlight", id).catch(() => {
|
|
2778
|
+
}),
|
|
2779
|
+
flashAvoidable: (on) => io.bridge("flash", !!on).catch(() => {
|
|
2780
|
+
}),
|
|
2781
|
+
originStatus: () => origin ? send({ type: "origin:status", origin }) : Promise.resolve(null),
|
|
2782
|
+
setOrigin: (cfg) => send({ type: "origin:set", origin, enabled: cfg.enabled, inject: cfg.inject, deferHook: cfg.deferHook }),
|
|
2783
|
+
requestPermission: () => chrome.permissions.request({ origins: [origin + "/*"] }),
|
|
2784
|
+
storage: {
|
|
2785
|
+
get: (key) => new Promise((resolve) => chrome.storage.local.get(`${key}:${origin}`, (got) => resolve(got ? got[`${key}:${origin}`] : void 0))),
|
|
2786
|
+
set: (key, value) => new Promise((resolve) => chrome.storage.local.set({ [`${key}:${origin}`]: value }, resolve))
|
|
2787
|
+
},
|
|
2788
|
+
badge(count) {
|
|
2789
|
+
if (panelPort) panelPort.postMessage({ type: "badge", count });
|
|
2790
|
+
},
|
|
2791
|
+
copy: (text) => navigator.clipboard.writeText(text).catch(() => {
|
|
2792
|
+
})
|
|
2793
|
+
};
|
|
2794
|
+
if (io.openResource) transport.openResource = io.openResource;
|
|
2795
|
+
if (io.undock) transport.undock = io.undock;
|
|
2796
|
+
if (io.readSource) transport.readSource = io.readSource;
|
|
2797
|
+
return transport;
|
|
2798
|
+
}
|
|
2799
|
+
function devtoolsIO() {
|
|
2800
|
+
const tabId = chrome.devtools.inspectedWindow.tabId;
|
|
2801
|
+
const evalIn = (code) => new Promise(
|
|
2802
|
+
(resolve, reject) => chrome.devtools.inspectedWindow.eval(code, (result, err) => {
|
|
2803
|
+
if (err && (err.isException || err.isError)) reject(new Error(err.value || err.description || "eval failed"));
|
|
2804
|
+
else resolve(result);
|
|
2805
|
+
})
|
|
2806
|
+
);
|
|
2807
|
+
const expressions = {
|
|
2808
|
+
info: () => "b.info()",
|
|
2809
|
+
pull: (since) => `b.pull?b.pull(${Number(since) || 0}):null`,
|
|
2810
|
+
replay: () => "b.replay()",
|
|
2811
|
+
clear: () => "b.clear()",
|
|
2812
|
+
configure: (o) => `b.configure(${JSON.stringify(o ?? {})})`,
|
|
2813
|
+
highlight: (id) => `b.highlight(${id === null || id === void 0 ? "null" : Number(id)})`,
|
|
2814
|
+
flash: (on) => `b.flashAvoidable(${!!on})`
|
|
2815
|
+
};
|
|
2816
|
+
return {
|
|
2817
|
+
tabId: () => tabId,
|
|
2818
|
+
origin: () => evalIn("location.origin"),
|
|
2819
|
+
bridge: (cmd, arg) => evalIn(`(function(){var b=window.__RERENDER_LENS_DEVTOOLS__;if(!b)return null;try{return (${expressions[cmd](arg)});}catch(e){return {__error:String(e)}}})()`).then((r) => {
|
|
2820
|
+
if (isRecord(r) && typeof r.__error === "string") throw new Error(r.__error);
|
|
2821
|
+
return r;
|
|
2822
|
+
}),
|
|
2823
|
+
onNavigated: (cb) => chrome.devtools.network.onNavigated.addListener(cb),
|
|
2824
|
+
openResource(url, line, col) {
|
|
2825
|
+
if (chrome.devtools.panels.openResource) chrome.devtools.panels.openResource(url, Math.max(0, (line || 1) - 1), Math.max(0, (col || 1) - 1), () => {
|
|
2826
|
+
});
|
|
2827
|
+
},
|
|
2828
|
+
undock: (mode) => openOutside(mode, tabId),
|
|
2829
|
+
readSource: (url) => new Promise((resolve) => {
|
|
2830
|
+
const bare = url.replace(/\?.*$/, "");
|
|
2831
|
+
chrome.devtools.inspectedWindow.getResources((resources) => {
|
|
2832
|
+
const res = resources.find((x) => x.url === url) || resources.find((x) => x.url.replace(/\?.*$/, "") === bare);
|
|
2833
|
+
if (!res) return resolve(null);
|
|
2834
|
+
res.getContent((content) => resolve(typeof content === "string" ? content : null));
|
|
2835
|
+
});
|
|
2836
|
+
})
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
function pageBridgeCommand(cmd, arg) {
|
|
2840
|
+
const b = window.__RERENDER_LENS_DEVTOOLS__;
|
|
2841
|
+
if (!b) return null;
|
|
2842
|
+
try {
|
|
2843
|
+
switch (cmd) {
|
|
2844
|
+
case "info":
|
|
2845
|
+
return b.info();
|
|
2846
|
+
case "pull":
|
|
2847
|
+
return b.pull ? b.pull(arg) : null;
|
|
2848
|
+
case "replay":
|
|
2849
|
+
b.replay?.();
|
|
2850
|
+
return true;
|
|
2851
|
+
case "clear":
|
|
2852
|
+
b.clear?.();
|
|
2853
|
+
return true;
|
|
2854
|
+
case "configure":
|
|
2855
|
+
return b.configure ? b.configure(arg) : null;
|
|
2856
|
+
case "highlight":
|
|
2857
|
+
return b.highlight ? b.highlight(arg) : false;
|
|
2858
|
+
case "flash":
|
|
2859
|
+
b.flashAvoidable?.(arg);
|
|
2860
|
+
return true;
|
|
2861
|
+
}
|
|
2862
|
+
} catch (e) {
|
|
2863
|
+
return { __error: String(e) };
|
|
2864
|
+
}
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
2867
|
+
async function openOutside(mode, tabId) {
|
|
2868
|
+
if (mode === "sidepanel") {
|
|
2869
|
+
const sp = chrome.sidePanel;
|
|
2870
|
+
if (!sp) throw new Error('This browser has no side panel API; use "Window" instead.');
|
|
2871
|
+
await sp.setOptions({ tabId, path: "sidepanel.html?tabId=" + encodeURIComponent(String(tabId)), enabled: true });
|
|
2872
|
+
await sp.open({ tabId });
|
|
2873
|
+
return true;
|
|
2874
|
+
}
|
|
2875
|
+
return new Promise(
|
|
2876
|
+
(resolve, reject) => chrome.runtime.sendMessage({ type: "window:open", tabId }, (res) => {
|
|
2877
|
+
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
|
|
2878
|
+
else if (!res || !res.ok) reject(new Error(res && res.error || "could not open a window"));
|
|
2879
|
+
else resolve(true);
|
|
2880
|
+
})
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
function standaloneIO(opts = {}) {
|
|
2884
|
+
let tabId = typeof opts.tabId === "number" ? opts.tabId : null;
|
|
2885
|
+
const pinned = tabId !== null;
|
|
2886
|
+
let label = null;
|
|
2887
|
+
const labelOf = (tab) => {
|
|
2888
|
+
if (!tab) return null;
|
|
2889
|
+
const o = tab.url ? tab.url.replace(/^https?:\/\//, "").replace(/\/.*$/, "") : "";
|
|
2890
|
+
return tab.title ? `${tab.title}${o ? " \xB7 " + o : ""}` : o || null;
|
|
2891
|
+
};
|
|
2892
|
+
const exec = (target, world, func, args = []) => chrome.scripting.executeScript({ target: { tabId: target }, world, func, args }).then((results) => results && results[0] ? results[0].result : null);
|
|
2893
|
+
const io = {
|
|
2894
|
+
tabId: () => tabId,
|
|
2895
|
+
tabLabel: label,
|
|
2896
|
+
async origin() {
|
|
2897
|
+
if (tabId === null) return null;
|
|
2898
|
+
try {
|
|
2899
|
+
return await exec(tabId, "ISOLATED", () => location.origin);
|
|
2900
|
+
} catch {
|
|
2901
|
+
try {
|
|
2902
|
+
const tab = await chrome.tabs.get(tabId);
|
|
2903
|
+
return tab && tab.url ? new URL(tab.url).origin : null;
|
|
2904
|
+
} catch {
|
|
2905
|
+
return null;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
},
|
|
2909
|
+
bridge: (cmd, arg) => {
|
|
2910
|
+
if (tabId === null) return Promise.resolve(null);
|
|
2911
|
+
return exec(tabId, "MAIN", pageBridgeCommand, [cmd, arg ?? null]).then((r) => {
|
|
2912
|
+
if (isRecord(r) && typeof r.__error === "string") throw new Error(r.__error);
|
|
2913
|
+
return r;
|
|
2914
|
+
});
|
|
2915
|
+
},
|
|
2916
|
+
onNavigated(cb) {
|
|
2917
|
+
chrome.tabs.onUpdated.addListener((id, info) => {
|
|
2918
|
+
if (id === tabId && info.status === "loading") cb();
|
|
2919
|
+
});
|
|
2920
|
+
},
|
|
2921
|
+
onTabChange(cb) {
|
|
2922
|
+
const announce = async () => {
|
|
2923
|
+
try {
|
|
2924
|
+
const tab = tabId === null ? void 0 : await chrome.tabs.get(tabId);
|
|
2925
|
+
label = labelOf(tab);
|
|
2926
|
+
} catch {
|
|
2927
|
+
label = null;
|
|
2928
|
+
}
|
|
2929
|
+
cb(label);
|
|
2930
|
+
};
|
|
2931
|
+
if (pinned) {
|
|
2932
|
+
chrome.tabs.onUpdated.addListener((id, info) => {
|
|
2933
|
+
if (id === tabId && (info.title || info.url)) void announce();
|
|
2934
|
+
});
|
|
2935
|
+
void announce();
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
chrome.tabs.onActivated.addListener(({ tabId: active }) => {
|
|
2939
|
+
tabId = active;
|
|
2940
|
+
void announce();
|
|
2941
|
+
});
|
|
2942
|
+
void chrome.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
|
|
2943
|
+
const t = tabs && tabs[0];
|
|
2944
|
+
if (t && typeof t.id === "number") {
|
|
2945
|
+
tabId = t.id;
|
|
2946
|
+
void announce();
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
},
|
|
2950
|
+
openResource: (url) => void chrome.tabs.create({ url }),
|
|
2951
|
+
undock: (mode) => tabId === null ? Promise.reject(new Error("no tab")) : openOutside(mode, tabId),
|
|
2952
|
+
// Host permission for the origin is required to fetch the module source (and present when the panel works at all).
|
|
2953
|
+
readSource: fetchSource
|
|
2954
|
+
};
|
|
2955
|
+
return io;
|
|
2956
|
+
}
|
|
2957
|
+
function bootExtension() {
|
|
2958
|
+
const theme = chrome.devtools.panels.themeName === "dark" ? "dark" : systemTheme();
|
|
2959
|
+
createPanel(document.getElementById("root"), createRelayTransport(devtoolsIO()), { theme });
|
|
2960
|
+
}
|
|
2961
|
+
function bootStandalone(opts = {}) {
|
|
2962
|
+
return createPanel(document.getElementById("root"), createRelayTransport(standaloneIO(opts)), { theme: systemTheme() });
|
|
2963
|
+
}
|
|
2964
|
+
function sampleReports() {
|
|
2965
|
+
const fn = (name) => FN_PREFIX + name;
|
|
2966
|
+
return [
|
|
2967
|
+
{
|
|
2968
|
+
component: "ProductRow",
|
|
2969
|
+
path: ["App", "ProductPage", "ProductList"],
|
|
2970
|
+
trigger: "parent",
|
|
2971
|
+
avoidable: true,
|
|
2972
|
+
renderCount: 1,
|
|
2973
|
+
instanceId: 4,
|
|
2974
|
+
commitId: 1,
|
|
2975
|
+
memoized: true,
|
|
2976
|
+
commitPriority: "immediate",
|
|
2977
|
+
owner: "ProductList",
|
|
2978
|
+
parent: { name: "ProductPage", trigger: "state" },
|
|
2979
|
+
selfDuration: 0.8,
|
|
2980
|
+
treeDuration: 1.1,
|
|
2981
|
+
source: { fileName: "http://localhost:5199/src/ProductList.tsx", lineNumber: 14, columnNumber: 7 },
|
|
2982
|
+
props: {
|
|
2983
|
+
prev: { product: { id: 1, name: "Keyboard", price: 49 }, style: { color: "red" }, onSelect: fn("onSelect"), selected: false },
|
|
2984
|
+
next: { product: { id: 1, name: "Keyboard", price: 49 }, style: { color: "red" }, onSelect: fn("onSelect"), selected: false }
|
|
2985
|
+
},
|
|
2986
|
+
propChanges: [
|
|
2987
|
+
{ path: "style", kind: "deep-equal", prev: { color: "red" }, next: { color: "red" } },
|
|
2988
|
+
{ path: "onSelect", kind: "function", prev: fn("onSelect"), next: fn("onSelect") }
|
|
2989
|
+
],
|
|
2990
|
+
stateChanges: [],
|
|
2991
|
+
hookChanges: [],
|
|
2992
|
+
reasons: [
|
|
2993
|
+
"caused by <ProductPage> re-rendering (its state changed).",
|
|
2994
|
+
'prop "style" is a new reference but deep-equal to the previous value: memoize the object with useMemo, or hoist it to module scope if it is constant.',
|
|
2995
|
+
`prop "onSelect" is a new function instance on every render: wrap it in useCallback (or hoist it out of the parent's render).`
|
|
2996
|
+
]
|
|
2997
|
+
},
|
|
2998
|
+
{
|
|
2999
|
+
component: "Toolbar",
|
|
3000
|
+
path: ["App", "ProductPage"],
|
|
3001
|
+
trigger: "parent",
|
|
3002
|
+
avoidable: true,
|
|
3003
|
+
renderCount: 1,
|
|
3004
|
+
instanceId: 2,
|
|
3005
|
+
commitId: 1,
|
|
3006
|
+
memoized: false,
|
|
3007
|
+
commitPriority: "immediate",
|
|
3008
|
+
owner: "ProductPage",
|
|
3009
|
+
parent: { name: "ProductPage", trigger: "state" },
|
|
3010
|
+
selfDuration: 0.3,
|
|
3011
|
+
props: { prev: { title: "Products", count: 3 }, next: { title: "Products", count: 3 } },
|
|
3012
|
+
propChanges: [],
|
|
3013
|
+
stateChanges: [],
|
|
3014
|
+
hookChanges: [],
|
|
3015
|
+
reasons: ['re-rendered with identical props because <ProductPage> re-rendered (its state changed). Wrap "Toolbar" in React.memo (or extend PureComponent).']
|
|
3016
|
+
},
|
|
3017
|
+
{
|
|
3018
|
+
component: "ProductPage",
|
|
3019
|
+
path: ["App"],
|
|
3020
|
+
trigger: "state",
|
|
3021
|
+
avoidable: false,
|
|
3022
|
+
renderCount: 1,
|
|
3023
|
+
instanceId: 3,
|
|
3024
|
+
commitId: 1,
|
|
3025
|
+
memoized: false,
|
|
3026
|
+
owner: "App",
|
|
3027
|
+
parent: null,
|
|
3028
|
+
props: { prev: { placeholder: "Search", filters: { sort: "asc", page: 1 } }, next: { placeholder: "Search", filters: { sort: "asc", page: 2 } } },
|
|
3029
|
+
propChanges: [{ path: "filters", kind: "different", prev: { sort: "asc", page: 1 }, next: { sort: "asc", page: 2 } }],
|
|
3030
|
+
stateChanges: [],
|
|
3031
|
+
hookChanges: [{ path: "useState#0", hook: "useState", index: 0, kind: "different", prev: "ab", next: "abc" }],
|
|
3032
|
+
reasons: ["useState #0 changed."],
|
|
3033
|
+
hookState: [
|
|
3034
|
+
{ path: "useState#0", hook: "useState", index: 0, value: "abc" },
|
|
3035
|
+
{ path: "useState#1", hook: "useState", index: 1, value: 3 },
|
|
3036
|
+
{ path: "useReducer#3", hook: "useReducer", index: 3, value: { cart: [1, 2], open: false } }
|
|
3037
|
+
],
|
|
3038
|
+
contexts: [{ name: "Theme", value: { mode: "light", user: "ann" } }]
|
|
3039
|
+
},
|
|
3040
|
+
{
|
|
3041
|
+
component: "Sidebar",
|
|
3042
|
+
path: ["App"],
|
|
3043
|
+
trigger: "hooks",
|
|
3044
|
+
avoidable: false,
|
|
3045
|
+
renderCount: 1,
|
|
3046
|
+
instanceId: 5,
|
|
3047
|
+
owner: "App",
|
|
3048
|
+
parent: null,
|
|
3049
|
+
commitId: 2,
|
|
3050
|
+
memoized: true,
|
|
3051
|
+
commitPriority: "normal",
|
|
3052
|
+
props: { prev: {}, next: {} },
|
|
3053
|
+
propChanges: [],
|
|
3054
|
+
stateChanges: [],
|
|
3055
|
+
hookChanges: [{ path: "useContext(Theme)", hook: "useContext", index: 0, kind: "different", prev: { mode: "light", user: "ann" }, next: { mode: "dark", user: "ann" }, provider: { component: "App", path: ["App"] }, changedKeys: ["mode"], totalKeys: 2 }],
|
|
3056
|
+
reasons: ['useContext(Theme) changed (provided by <App>): only "mode" of 2 keys changed, yet every consumer re-renders. Split the context or memoize the slices consumers read.']
|
|
3057
|
+
}
|
|
3058
|
+
];
|
|
3059
|
+
}
|
|
3060
|
+
function floodReports(n) {
|
|
3061
|
+
const out = [];
|
|
3062
|
+
const components = Math.max(10, Math.floor(n / 10));
|
|
3063
|
+
for (let i = 0; i < n; i++) {
|
|
3064
|
+
const id = i % components;
|
|
3065
|
+
const depth = 1 + id % 6;
|
|
3066
|
+
const path = ["App"];
|
|
3067
|
+
for (let d = 1; d < depth; d++) path.push(`Section${(id * 7 + d) % 40}`);
|
|
3068
|
+
const avoidable = id % 3 !== 0;
|
|
3069
|
+
out.push({
|
|
3070
|
+
component: `Item${id}`,
|
|
3071
|
+
path,
|
|
3072
|
+
trigger: avoidable ? "parent" : "props",
|
|
3073
|
+
avoidable,
|
|
3074
|
+
renderCount: Math.floor(i / components) + 1,
|
|
3075
|
+
instanceId: id + 1,
|
|
3076
|
+
commitId: Math.floor(i / 50) + 1,
|
|
3077
|
+
memoized: id % 2 === 0,
|
|
3078
|
+
owner: path[path.length - 1],
|
|
3079
|
+
parent: { name: path[path.length - 1], trigger: "state" },
|
|
3080
|
+
selfDuration: id % 7 / 10,
|
|
3081
|
+
props: { prev: { style: { w: id }, n: i }, next: { style: { w: id }, n: i + (avoidable ? 0 : 1) } },
|
|
3082
|
+
propChanges: avoidable ? [{ path: "style", kind: "deep-equal", prev: { w: id }, next: { w: id } }] : [{ path: "n", kind: "different", prev: i, next: i + 1 }],
|
|
3083
|
+
stateChanges: [],
|
|
3084
|
+
hookChanges: [],
|
|
3085
|
+
reasons: [avoidable ? 'prop "style" is a new reference but deep-equal to the previous value.' : 'prop "n" changed.']
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
return out;
|
|
3089
|
+
}
|
|
3090
|
+
function createBroadcastTransport(name) {
|
|
3091
|
+
let listener = null;
|
|
3092
|
+
let channel = null;
|
|
3093
|
+
const emit = (m) => {
|
|
3094
|
+
if (listener) listener(m);
|
|
3095
|
+
};
|
|
3096
|
+
const open = () => {
|
|
3097
|
+
if (channel) return channel;
|
|
3098
|
+
channel = new BroadcastChannel(name);
|
|
3099
|
+
channel.onmessage = (event) => {
|
|
3100
|
+
const data = event.data;
|
|
3101
|
+
if (!data || reply(data)) return;
|
|
3102
|
+
if (data.__rerenderLens === true && typeof data.type === "string") emit({ type: data.type, version: typeof data.version === "number" ? data.version : void 0, payload: data.payload });
|
|
3103
|
+
};
|
|
3104
|
+
return channel;
|
|
3105
|
+
};
|
|
3106
|
+
const { bridge, reply } = commandBridge((message) => open().postMessage(message), 1500);
|
|
3107
|
+
const transport = {
|
|
3108
|
+
origin: location.origin,
|
|
3109
|
+
tabLabel: `channel "${name}"`,
|
|
3110
|
+
subscribe(fn) {
|
|
3111
|
+
listener = fn;
|
|
3112
|
+
open();
|
|
3113
|
+
fn({ type: "connected" });
|
|
3114
|
+
void bridge("info").then(async (info) => {
|
|
3115
|
+
if (!info) {
|
|
3116
|
+
fn({ type: "disconnected" });
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
fn({ type: "hello", version: info.protocol, payload: info });
|
|
3120
|
+
const res = await bridge("pull", 0);
|
|
3121
|
+
if (res) for (const p of res.reports) fn({ type: "report", payload: p });
|
|
3122
|
+
});
|
|
3123
|
+
},
|
|
3124
|
+
replay: () => void bridge("replay"),
|
|
3125
|
+
clear: () => void bridge("clear"),
|
|
3126
|
+
configure: (options) => bridge("configure", options).then((r) => r ?? void 0),
|
|
3127
|
+
highlight: (id) => bridge("highlight", id).catch(() => {
|
|
3128
|
+
}),
|
|
3129
|
+
flashAvoidable: (on) => bridge("flash", !!on).catch(() => {
|
|
3130
|
+
}),
|
|
3131
|
+
storage: localStorageAdapter(`rerender-lens:${name}`),
|
|
3132
|
+
readSource: fetchSource,
|
|
3133
|
+
copy: (text) => navigator.clipboard.writeText(text).catch(() => {
|
|
3134
|
+
})
|
|
3135
|
+
};
|
|
3136
|
+
return transport;
|
|
3137
|
+
}
|
|
3138
|
+
function createRelayClientTransport(relayUrl, ES = EventSource) {
|
|
3139
|
+
const base = relayUrl.replace(/\/$/, "");
|
|
3140
|
+
let listener = null;
|
|
3141
|
+
let stream = null;
|
|
3142
|
+
let appsOnline = null;
|
|
3143
|
+
const emit = (m) => {
|
|
3144
|
+
if (listener) listener(m);
|
|
3145
|
+
};
|
|
3146
|
+
const post = (message) => fetch(`${base}/message`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }).then(() => void 0);
|
|
3147
|
+
const { bridge, reply } = commandBridge(post, 2e3);
|
|
3148
|
+
const handleData = (data) => {
|
|
3149
|
+
let parsed;
|
|
3150
|
+
try {
|
|
3151
|
+
parsed = JSON.parse(data);
|
|
3152
|
+
} catch {
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
for (const m of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
3156
|
+
if (!isRecord(m) || reply(m)) continue;
|
|
3157
|
+
if (m.__rerenderLens === true && m.type === "relay") {
|
|
3158
|
+
const apps = isRecord(m.payload) && typeof m.payload.apps === "number" ? m.payload.apps : 0;
|
|
3159
|
+
const wasOnline = appsOnline;
|
|
3160
|
+
appsOnline = apps;
|
|
3161
|
+
if (apps > 0 && wasOnline !== null && wasOnline === 0) void attach();
|
|
3162
|
+
else if (apps === 0) emit({ type: "disconnected" });
|
|
3163
|
+
} else if (m.__rerenderLens === true && typeof m.type === "string") emit({ type: m.type, version: typeof m.version === "number" ? m.version : void 0, payload: m.payload });
|
|
3164
|
+
}
|
|
3165
|
+
};
|
|
3166
|
+
async function attach() {
|
|
3167
|
+
const info = await bridge("info");
|
|
3168
|
+
if (!info) {
|
|
3169
|
+
emit({ type: "disconnected" });
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
emit({ type: "connected" });
|
|
3173
|
+
emit({ type: "hello", version: info.protocol, payload: info });
|
|
3174
|
+
const res = await bridge("pull", 0);
|
|
3175
|
+
if (res) for (const p of res.reports) emit({ type: "report", payload: p });
|
|
3176
|
+
}
|
|
3177
|
+
const transport = {
|
|
3178
|
+
origin: base,
|
|
3179
|
+
tabLabel: `relay ${base.replace(/^https?:\/\//, "")}`,
|
|
3180
|
+
subscribe(fn) {
|
|
3181
|
+
listener = fn;
|
|
3182
|
+
const open = () => {
|
|
3183
|
+
stream = new ES(`${base}/events?role=panel`);
|
|
3184
|
+
stream.onmessage = (e) => handleData(e.data);
|
|
3185
|
+
stream.onerror = () => {
|
|
3186
|
+
emit({ type: "disconnected" });
|
|
3187
|
+
};
|
|
3188
|
+
};
|
|
3189
|
+
open();
|
|
3190
|
+
void attach();
|
|
3191
|
+
},
|
|
3192
|
+
replay: () => void bridge("replay"),
|
|
3193
|
+
clear: () => void bridge("clear"),
|
|
3194
|
+
configure: (options) => bridge("configure", options).then((r) => r ?? void 0),
|
|
3195
|
+
highlight: (id) => bridge("highlight", id).catch(() => {
|
|
3196
|
+
}),
|
|
3197
|
+
flashAvoidable: (on) => bridge("flash", !!on).catch(() => {
|
|
3198
|
+
}),
|
|
3199
|
+
storage: localStorageAdapter(`rerender-lens:relay:${base}`),
|
|
3200
|
+
readSource: fetchSource,
|
|
3201
|
+
copy: (text) => navigator.clipboard.writeText(text).catch(() => {
|
|
3202
|
+
})
|
|
3203
|
+
};
|
|
3204
|
+
return transport;
|
|
3205
|
+
}
|
|
3206
|
+
function bootRelay(relayUrl) {
|
|
3207
|
+
return createPanel(document.getElementById("root"), createRelayClientTransport(relayUrl), { theme: systemTheme() });
|
|
3208
|
+
}
|
|
3209
|
+
function bootBroadcast(name) {
|
|
3210
|
+
return createPanel(document.getElementById("root"), createBroadcastTransport(name), { theme: systemTheme() });
|
|
3211
|
+
}
|
|
3212
|
+
function bootDemo() {
|
|
3213
|
+
const params2 = new URLSearchParams(location.search);
|
|
3214
|
+
const flood = Number(params2.get("flood") || 0);
|
|
3215
|
+
const sample = sampleReports();
|
|
3216
|
+
let i = 0;
|
|
3217
|
+
let commit = 0;
|
|
3218
|
+
const mem = {};
|
|
3219
|
+
const transport = {
|
|
3220
|
+
origin: "http://localhost:5199",
|
|
3221
|
+
subscribe(fn) {
|
|
3222
|
+
fn({ type: "connected" });
|
|
3223
|
+
fn({ type: "hello", version: PROTOCOL, payload: { library: "demo", protocol: PROTOCOL, react: [{ version: "19.2.0", bundleType: 1 }], production: false, enabled: true, options: { trackAllMemoized: true, silent: true }, source: "page", injected: false } });
|
|
3224
|
+
if (flood > 0) {
|
|
3225
|
+
const t0 = performance.now();
|
|
3226
|
+
for (const r of floodReports(flood)) fn({ type: "report", payload: r });
|
|
3227
|
+
requestAnimationFrame(() => console.log(`[rerender-lens demo] ${flood} reports ingested and rendered in ${(performance.now() - t0).toFixed(0)} ms`));
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
const tick = () => {
|
|
3231
|
+
const r = JSON.parse(JSON.stringify(sample[i % sample.length]));
|
|
3232
|
+
r.renderCount = Math.floor(i / sample.length) + 1;
|
|
3233
|
+
if (i % sample.length === 0) commit++;
|
|
3234
|
+
r.commitId = commit + (r.commitId === 2 ? 100 : 0);
|
|
3235
|
+
fn({ type: "report", payload: r });
|
|
3236
|
+
i++;
|
|
3237
|
+
if (i < 14) setTimeout(tick, i < 4 ? 50 : 900);
|
|
3238
|
+
};
|
|
3239
|
+
tick();
|
|
3240
|
+
},
|
|
3241
|
+
replay() {
|
|
3242
|
+
},
|
|
3243
|
+
clear() {
|
|
3244
|
+
},
|
|
3245
|
+
configure: (o) => Promise.resolve(o),
|
|
3246
|
+
highlight() {
|
|
3247
|
+
},
|
|
3248
|
+
flashAvoidable() {
|
|
3249
|
+
},
|
|
3250
|
+
originStatus: () => Promise.resolve({ origin: "http://localhost:5199", builtIn: true, permitted: true, enabled: true, inject: false, deferHook: false }),
|
|
3251
|
+
setOrigin: () => Promise.resolve(),
|
|
3252
|
+
storage: { get: (k) => Promise.resolve(mem[k]), set: (k, v) => Promise.resolve(mem[k] = v) },
|
|
3253
|
+
readSource: () => Promise.resolve("import { memo } from 'react';\n\nexport const ProductList = memo(function ProductList(props) {\n const [selected, setSelected] = useState(null);\n return (\n <ul>\n {props.products.map((p) => (\n <ProductRow key={p.id} product={p} style={{ color: 'red' }} onSelect={(id) => props.onSelect(id)} />\n ))}\n </ul>\n );\n});\n")
|
|
3254
|
+
};
|
|
3255
|
+
createPanel(document.getElementById("root"), transport, { theme: /theme=dark/.test(location.search) ? "dark" : systemTheme() });
|
|
3256
|
+
}
|
|
3257
|
+
var api = {
|
|
3258
|
+
PROTOCOL,
|
|
3259
|
+
createPanel,
|
|
3260
|
+
summarize,
|
|
3261
|
+
valueNode,
|
|
3262
|
+
reportView,
|
|
3263
|
+
fixView,
|
|
3264
|
+
normalizeReport,
|
|
3265
|
+
reportToMarkdown,
|
|
3266
|
+
sampleReports,
|
|
3267
|
+
floodReports,
|
|
3268
|
+
bootStandalone,
|
|
3269
|
+
bootBroadcast,
|
|
3270
|
+
bootRelay,
|
|
3271
|
+
createRelayTransport,
|
|
3272
|
+
createBroadcastTransport,
|
|
3273
|
+
createRelayClientTransport,
|
|
3274
|
+
sourceContext,
|
|
3275
|
+
analysis: { firstDifferentPath, diffLeaves, fixesFor, rankFixes, rootCauseOf, analyzeCommit, contextAttribution, cascadeTree, rootCauseSummary, summarizeSession, compareSessions }
|
|
3276
|
+
};
|
|
3277
|
+
window.RerenderLensPanel = api;
|
|
3278
|
+
var hasChrome = typeof chrome !== "undefined" && !!chrome && !!chrome.runtime && !!chrome.runtime.id;
|
|
3279
|
+
var hasDevtools = hasChrome && !!chrome.devtools && !!chrome.devtools.inspectedWindow;
|
|
3280
|
+
var params = typeof location !== "undefined" ? new URLSearchParams(location.search) : new URLSearchParams();
|
|
3281
|
+
var pathname = typeof location !== "undefined" ? String(location.pathname) : "";
|
|
3282
|
+
if (params.has("relay") && typeof EventSource === "function") bootRelay(params.get("relay") || location.origin);
|
|
3283
|
+
else if (params.has("channel") && typeof BroadcastChannel === "function") bootBroadcast(params.get("channel") || "rerender-lens");
|
|
3284
|
+
else if (hasDevtools && /panel\.html/.test(pathname) && !params.has("tabId")) bootExtension();
|
|
3285
|
+
else if (hasChrome && (/sidepanel\.html/.test(pathname) || params.has("tabId"))) bootStandalone({ tabId: params.has("tabId") ? Number(params.get("tabId")) : null });
|
|
3286
|
+
else if (params.has("demo")) bootDemo();
|
|
3287
|
+
})();
|