@progen-labs/spectra 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-BvBsPok5.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +551 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +750 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +689 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +118 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +200 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { fmtMs } from '../format.js';
|
|
2
|
+
import CodeRef from './CodeRef.jsx';
|
|
3
|
+
|
|
4
|
+
const CATS = [
|
|
5
|
+
['scripting', 'Scripting', 'var(--cat-1)'],
|
|
6
|
+
['layout', 'Layout', 'var(--cat-2)'],
|
|
7
|
+
['style', 'Style', 'var(--cat-3)'],
|
|
8
|
+
['paint', 'Paint', 'var(--cat-4)'],
|
|
9
|
+
['gc', 'GC', 'var(--cat-5)'],
|
|
10
|
+
['compile', 'Compile', 'var(--cat-6)'],
|
|
11
|
+
['parse', 'Parse', 'var(--cat-6)'],
|
|
12
|
+
['other', 'Other', 'var(--cat-other)'],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Every main-thread task over 8 ms on a time axis; long tasks (>50 ms) stand out. */
|
|
16
|
+
function Timeline({ report }) {
|
|
17
|
+
const total = Math.max(
|
|
18
|
+
report.meta.durationMs,
|
|
19
|
+
...report.timeline.map(t => t.startMs + t.durMs),
|
|
20
|
+
1,
|
|
21
|
+
);
|
|
22
|
+
const maxDur = Math.max(...report.timeline.map(t => t.durMs), 50);
|
|
23
|
+
const H = 90;
|
|
24
|
+
return (
|
|
25
|
+
<svg
|
|
26
|
+
className="timeline"
|
|
27
|
+
viewBox={`0 0 1000 ${H + 18}`}
|
|
28
|
+
preserveAspectRatio="none"
|
|
29
|
+
role="img"
|
|
30
|
+
aria-label="Main-thread tasks over time"
|
|
31
|
+
>
|
|
32
|
+
<line
|
|
33
|
+
x1="0"
|
|
34
|
+
x2="1000"
|
|
35
|
+
y1={H - (50 / maxDur) * H}
|
|
36
|
+
y2={H - (50 / maxDur) * H}
|
|
37
|
+
className="limit"
|
|
38
|
+
/>
|
|
39
|
+
{report.timeline.map(t => (
|
|
40
|
+
<rect
|
|
41
|
+
key={`${t.startMs}-${t.durMs}`}
|
|
42
|
+
x={(t.startMs / total) * 1000}
|
|
43
|
+
width={Math.max((t.durMs / total) * 1000, 1.5)}
|
|
44
|
+
y={H - (t.durMs / maxDur) * H}
|
|
45
|
+
height={(t.durMs / maxDur) * H}
|
|
46
|
+
className={t.durMs >= 50 ? 'task long' : 'task'}
|
|
47
|
+
>
|
|
48
|
+
<title>{`${fmtMs(t.durMs)} at ${(t.startMs / 1000).toFixed(2)}s`}</title>
|
|
49
|
+
</rect>
|
|
50
|
+
))}
|
|
51
|
+
<text x="2" y={H + 13} className="axis">
|
|
52
|
+
0
|
|
53
|
+
</text>
|
|
54
|
+
<text x="998" y={H + 13} className="axis" textAnchor="end">
|
|
55
|
+
{(total / 1000).toFixed(1)} s
|
|
56
|
+
</text>
|
|
57
|
+
<text
|
|
58
|
+
x="998"
|
|
59
|
+
y={H - (50 / maxDur) * H - 3}
|
|
60
|
+
className="axis"
|
|
61
|
+
textAnchor="end"
|
|
62
|
+
>
|
|
63
|
+
50 ms
|
|
64
|
+
</text>
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function LongTasks({ report, onOpenFile }) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="long-tasks">
|
|
72
|
+
<Timeline report={report} />
|
|
73
|
+
{report.longTasks.length === 0 ? (
|
|
74
|
+
<p className="muted pad">No task exceeded 50 ms in this recording.</p>
|
|
75
|
+
) : (
|
|
76
|
+
<ul className="task-list">
|
|
77
|
+
{report.longTasks.map(t => {
|
|
78
|
+
const sum =
|
|
79
|
+
CATS.reduce((s, [k]) => s + (t.breakdown[k] || 0), 0) || 1;
|
|
80
|
+
return (
|
|
81
|
+
<li
|
|
82
|
+
key={t.startMs}
|
|
83
|
+
className={t.durMs >= 200 ? 'sev-critical' : 'sev-warning'}
|
|
84
|
+
>
|
|
85
|
+
<div className="task-head">
|
|
86
|
+
<b>{fmtMs(t.durMs)}</b>
|
|
87
|
+
<span>at {(t.startMs / 1000).toFixed(2)} s</span>
|
|
88
|
+
<span className="chip">{t.trigger.type}</span>
|
|
89
|
+
{t.trigger.detail && t.trigger.detail !== t.trigger.type && (
|
|
90
|
+
<span className="muted mono">{t.trigger.detail}</span>
|
|
91
|
+
)}
|
|
92
|
+
{t.thrash && (
|
|
93
|
+
<span className="chip chip-warn">layout thrashing</span>
|
|
94
|
+
)}
|
|
95
|
+
{t.forcedCount > 0 && (
|
|
96
|
+
<span className="muted">
|
|
97
|
+
{t.forcedCount} forced layouts
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
<div className="breakdown-bar small">
|
|
102
|
+
{CATS.map(([k, label, color]) =>
|
|
103
|
+
t.breakdown[k] > 0 ? (
|
|
104
|
+
<span
|
|
105
|
+
key={k}
|
|
106
|
+
style={{
|
|
107
|
+
width: `${(t.breakdown[k] / sum) * 100}%`,
|
|
108
|
+
background: color,
|
|
109
|
+
}}
|
|
110
|
+
title={`${label}: ${fmtMs(t.breakdown[k])}`}
|
|
111
|
+
/>
|
|
112
|
+
) : null,
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
<div className="task-cols">
|
|
116
|
+
<div>
|
|
117
|
+
<h4>Files</h4>
|
|
118
|
+
{t.topFiles.map(f => (
|
|
119
|
+
<button
|
|
120
|
+
key={f.file}
|
|
121
|
+
type="button"
|
|
122
|
+
className="link mono row"
|
|
123
|
+
onClick={() => onOpenFile(f.file)}
|
|
124
|
+
>
|
|
125
|
+
<span>{f.file}</span>
|
|
126
|
+
<b>{fmtMs(f.ms)}</b>
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
<div>
|
|
131
|
+
<h4>Functions</h4>
|
|
132
|
+
{t.topFunctions.map(f => (
|
|
133
|
+
<div
|
|
134
|
+
key={`${f.name}${f.file}${f.line}`}
|
|
135
|
+
className="row mono"
|
|
136
|
+
>
|
|
137
|
+
<span title={`${f.file}${f.line ? `:${f.line}` : ''}`}>
|
|
138
|
+
{f.name}()
|
|
139
|
+
<CodeRef
|
|
140
|
+
file={f.file}
|
|
141
|
+
line={f.line}
|
|
142
|
+
lineKind={f.lineKind}
|
|
143
|
+
abs={f.abs}
|
|
144
|
+
onOpenFile={onOpenFile}
|
|
145
|
+
/>
|
|
146
|
+
</span>
|
|
147
|
+
<b>{fmtMs(f.ms)}</b>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</li>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</ul>
|
|
156
|
+
)}
|
|
157
|
+
{report.forcedReflows.length > 0 && (
|
|
158
|
+
<section className="pad">
|
|
159
|
+
<h2>
|
|
160
|
+
Forced reflows{' '}
|
|
161
|
+
<span className="muted">
|
|
162
|
+
(layout/style recalculated synchronously by script)
|
|
163
|
+
</span>
|
|
164
|
+
</h2>
|
|
165
|
+
<table className="data">
|
|
166
|
+
<thead>
|
|
167
|
+
<tr>
|
|
168
|
+
<th>Where</th>
|
|
169
|
+
<th>Kind</th>
|
|
170
|
+
<th>Count</th>
|
|
171
|
+
<th>Time</th>
|
|
172
|
+
</tr>
|
|
173
|
+
</thead>
|
|
174
|
+
<tbody>
|
|
175
|
+
{report.forcedReflows.slice(0, 20).map(f => (
|
|
176
|
+
<tr key={`${f.kind}${f.file}${f.fn}${f.line}`}>
|
|
177
|
+
<td>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
className="link mono"
|
|
181
|
+
onClick={() => f.file && onOpenFile(f.file)}
|
|
182
|
+
>
|
|
183
|
+
{f.fn && f.fn !== '(anonymous)' ? `${f.fn} — ` : ''}
|
|
184
|
+
{f.file || 'unknown'}
|
|
185
|
+
{f.line ? `:${f.line}` : ''}
|
|
186
|
+
</button>
|
|
187
|
+
</td>
|
|
188
|
+
<td>{f.kind}</td>
|
|
189
|
+
<td className="num">{f.count}</td>
|
|
190
|
+
<td className="num">{fmtMs(f.ms)}</td>
|
|
191
|
+
</tr>
|
|
192
|
+
))}
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
</section>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { fmtBytes, fmtMs, fmtNumber } from '../format.js';
|
|
2
|
+
|
|
3
|
+
function Table({ title, rows, columns }) {
|
|
4
|
+
if (!rows.length) return null;
|
|
5
|
+
return (
|
|
6
|
+
<section>
|
|
7
|
+
<h2>{title}</h2>
|
|
8
|
+
<table className="data">
|
|
9
|
+
<thead>
|
|
10
|
+
<tr>
|
|
11
|
+
{columns.map(c => (
|
|
12
|
+
<th key={c.label}>{c.label}</th>
|
|
13
|
+
))}
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
{rows.map(r => (
|
|
18
|
+
<tr key={`${r.url}${r.startMs}`}>
|
|
19
|
+
{columns.map(c => (
|
|
20
|
+
<td
|
|
21
|
+
key={c.label}
|
|
22
|
+
className={c.num ? 'num' : 'mono'}
|
|
23
|
+
title={c.title?.(r)}
|
|
24
|
+
>
|
|
25
|
+
{c.get(r)}
|
|
26
|
+
</td>
|
|
27
|
+
))}
|
|
28
|
+
</tr>
|
|
29
|
+
))}
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function NetworkPanel({ report }) {
|
|
37
|
+
const { network, memory, totals } = report;
|
|
38
|
+
const cols = [
|
|
39
|
+
{ label: 'Resource', get: r => r.name, title: r => r.url },
|
|
40
|
+
{ label: 'Type', get: r => r.type },
|
|
41
|
+
{ label: 'Time', get: r => fmtMs(r.durMs), num: true },
|
|
42
|
+
{ label: 'Over the wire', get: r => fmtBytes(r.encoded), num: true },
|
|
43
|
+
{ label: 'Decoded', get: r => fmtBytes(r.decoded), num: true },
|
|
44
|
+
];
|
|
45
|
+
return (
|
|
46
|
+
<div className="network pad">
|
|
47
|
+
<div className="summary-row">
|
|
48
|
+
<span>{fmtNumber(network.count)} requests</span>
|
|
49
|
+
<span>{fmtBytes(network.totalEncoded)} transferred</span>
|
|
50
|
+
<span>{fmtBytes(network.totalDecoded)} decoded</span>
|
|
51
|
+
<span>{fmtNumber(memory.domNodes)} DOM nodes</span>
|
|
52
|
+
<span>{fmtNumber(memory.listeners)} event listeners</span>
|
|
53
|
+
<span>
|
|
54
|
+
heap {memory.heapUsedMb} MB ({memory.heapGrowthMb >= 0 ? '+' : ''}
|
|
55
|
+
{memory.heapGrowthMb} MB)
|
|
56
|
+
</span>
|
|
57
|
+
<span>
|
|
58
|
+
{fmtNumber(totals.layoutCount)} layouts ·{' '}
|
|
59
|
+
{fmtNumber(totals.styleRecalcCount)} style recalcs
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
<Table title="Slowest requests" rows={network.slow} columns={cols} />
|
|
63
|
+
<Table title="Largest resources" rows={network.large} columns={cols} />
|
|
64
|
+
<Table
|
|
65
|
+
title="Served uncompressed"
|
|
66
|
+
rows={network.uncompressed}
|
|
67
|
+
columns={cols}
|
|
68
|
+
/>
|
|
69
|
+
{network.duplicates.length > 0 && (
|
|
70
|
+
<section>
|
|
71
|
+
<h2>Requested repeatedly</h2>
|
|
72
|
+
<ul>
|
|
73
|
+
{network.duplicates.map(d => (
|
|
74
|
+
<li key={d.url} title={d.url}>
|
|
75
|
+
{d.name} <b>{d.count}×</b>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
</section>
|
|
80
|
+
)}
|
|
81
|
+
{report.vitals.shifts.length > 0 && (
|
|
82
|
+
<section>
|
|
83
|
+
<h2>Layout shifts</h2>
|
|
84
|
+
<ul>
|
|
85
|
+
{report.vitals.shifts.map(s => (
|
|
86
|
+
<li key={`${s.start}${s.value}`}>
|
|
87
|
+
<b>{s.value}</b> at {(s.start / 1000).toFixed(2)} s{' '}
|
|
88
|
+
{s.sources.length > 0 && (
|
|
89
|
+
<span className="mono muted">— {s.sources.join(', ')}</span>
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
</section>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { fmtMs, fmtNumber } from '../format.js';
|
|
3
|
+
|
|
4
|
+
const MAX_PINS = 14;
|
|
5
|
+
const MIN_SELF_MS = 0.5;
|
|
6
|
+
const HUGE_AREA = 0.7;
|
|
7
|
+
|
|
8
|
+
const REASON_LABELS = {
|
|
9
|
+
props: 'props changed',
|
|
10
|
+
state: 'state changed',
|
|
11
|
+
hooks: 'hook state changed',
|
|
12
|
+
context: 'context changed',
|
|
13
|
+
parent: 'parent re-rendered with nothing new',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildPins(report, hideVendor) {
|
|
17
|
+
const { pageView } = report;
|
|
18
|
+
const byId = new Map((report.react && report.react.components ? report.react.components : []).map(c => [c.id, c]));
|
|
19
|
+
const viewArea = pageView.width * pageView.height;
|
|
20
|
+
const candidates = [];
|
|
21
|
+
for (const entry of pageView.entries) {
|
|
22
|
+
const c = byId.get(entry.id);
|
|
23
|
+
if (!c || (hideVendor && c.vendor)) continue;
|
|
24
|
+
if (c.selfMs < MIN_SELF_MS && !c.wasted) continue;
|
|
25
|
+
const [x, y, w, h] = entry.rects[0];
|
|
26
|
+
candidates.push({ c, rect: { x, y, w, h }, count: entry.count });
|
|
27
|
+
}
|
|
28
|
+
// Page-sized wrappers say nothing about where the time goes; keep them only
|
|
29
|
+
// when nothing smaller was found.
|
|
30
|
+
const focused = candidates.filter(
|
|
31
|
+
p => (p.rect.w * p.rect.h) / viewArea < HUGE_AREA,
|
|
32
|
+
);
|
|
33
|
+
const pool = focused.length ? focused : candidates;
|
|
34
|
+
return pool
|
|
35
|
+
.sort((a, b) => b.c.selfMs + b.c.wastedMs - (a.c.selfMs + a.c.wastedMs))
|
|
36
|
+
.slice(0, MAX_PINS)
|
|
37
|
+
.map((p, i) => ({ ...p, key: `${p.c.id}`, n: i + 1 }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function severityOf(c, maxSelf) {
|
|
41
|
+
if (c.renders && c.wasted / c.renders > 0.5 && c.wasted > 5) return 'warn';
|
|
42
|
+
return c.selfMs >= maxSelf * 0.5 ? 'hot' : 'mild';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function topReason(c) {
|
|
46
|
+
const [key, n] =
|
|
47
|
+
Object.entries(c.reasons).sort((a, b) => b[1] - a[1])[0] || [];
|
|
48
|
+
return n ? `${n}× ${REASON_LABELS[key]}` : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Details({ pin }) {
|
|
52
|
+
const { c } = pin;
|
|
53
|
+
const reason = topReason(c);
|
|
54
|
+
const unstable = c.unstableProps[0];
|
|
55
|
+
return (
|
|
56
|
+
<div className="pin-card-body">
|
|
57
|
+
<div className="pin-card-title">
|
|
58
|
+
<span className="pin-num">{pin.n}</span>
|
|
59
|
+
<b>{c.name}</b>
|
|
60
|
+
</div>
|
|
61
|
+
<code className="pin-file">
|
|
62
|
+
{c.file ? `${c.file}${c.line ? `:${c.line}` : ''}` : 'file unknown'}
|
|
63
|
+
</code>
|
|
64
|
+
<dl className="pin-stats">
|
|
65
|
+
<div>
|
|
66
|
+
<dt>Self time</dt>
|
|
67
|
+
<dd>{fmtMs(c.selfMs)}</dd>
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<dt>Renders</dt>
|
|
71
|
+
<dd>{fmtNumber(c.renders)}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
<dt>Wasted</dt>
|
|
75
|
+
<dd className={c.wasted ? 'warn' : ''}>{fmtNumber(c.wasted)}</dd>
|
|
76
|
+
</div>
|
|
77
|
+
<div>
|
|
78
|
+
<dt>Slowest</dt>
|
|
79
|
+
<dd>{fmtMs(c.maxMs)}</dd>
|
|
80
|
+
</div>
|
|
81
|
+
</dl>
|
|
82
|
+
{reason && <p>Mostly {reason}.</p>}
|
|
83
|
+
{unstable && (
|
|
84
|
+
<p className="pin-fix">
|
|
85
|
+
Fix: <code>{unstable.prop}</code> is a new {unstable.kind} on almost
|
|
86
|
+
every render — memoize it or pass a stable reference.
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
{!unstable && c.wasted > 0 && (
|
|
90
|
+
<p className="pin-fix">
|
|
91
|
+
Fix: {fmtNumber(c.wasted)} renders changed nothing — wrap in{' '}
|
|
92
|
+
<code>React.memo</code> or stop the parent re-rendering.
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
{pin.count > 1 && (
|
|
96
|
+
<p className="muted">
|
|
97
|
+
{pin.count} instances on screen; the largest is outlined.
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const GUTTER_RATIO = 0.06;
|
|
105
|
+
const MIN_GUTTER = 56;
|
|
106
|
+
const MARKER_GAP_RATIO = 0.058;
|
|
107
|
+
const ANCHOR_INSET = 8;
|
|
108
|
+
|
|
109
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Puts each pin's number in the left or right gutter (nearer side), as close as
|
|
113
|
+
* possible to the element's height but never closer than `gap` to a neighbour,
|
|
114
|
+
* so numbers never overlap however the elements are nested.
|
|
115
|
+
*/
|
|
116
|
+
function placeMarkers(pins, view, gutter) {
|
|
117
|
+
const gap = view.height * MARKER_GAP_RATIO;
|
|
118
|
+
const placed = new Map();
|
|
119
|
+
['left', 'right'].forEach(side => {
|
|
120
|
+
const group = pins
|
|
121
|
+
.filter(
|
|
122
|
+
p =>
|
|
123
|
+
(p.rect.x + p.rect.w / 2 < view.width / 2 ? 'left' : 'right') ===
|
|
124
|
+
side,
|
|
125
|
+
)
|
|
126
|
+
.map(pin => ({ pin, y: pin.rect.y + pin.rect.h / 2 }))
|
|
127
|
+
.sort((a, b) => a.y - b.y);
|
|
128
|
+
group.forEach((g, i) => {
|
|
129
|
+
g.y = Math.max(gap / 2, i ? Math.max(g.y, group[i - 1].y + gap) : g.y);
|
|
130
|
+
});
|
|
131
|
+
for (let i = group.length - 1; i >= 0; i -= 1) {
|
|
132
|
+
const limit =
|
|
133
|
+
i === group.length - 1 ? view.height - gap / 2 : group[i + 1].y - gap;
|
|
134
|
+
group[i].y = Math.min(group[i].y, limit);
|
|
135
|
+
}
|
|
136
|
+
group.forEach(({ pin, y }) => {
|
|
137
|
+
const { x, y: top, w, h } = pin.rect;
|
|
138
|
+
const inset = Math.min(ANCHOR_INSET, w / 4);
|
|
139
|
+
placed.set(pin.key, {
|
|
140
|
+
side,
|
|
141
|
+
markerX: side === 'left' ? gutter / 2 : view.width + gutter * 1.5,
|
|
142
|
+
markerY: y,
|
|
143
|
+
anchorX: gutter + (side === 'left' ? x + inset : x + w - inset),
|
|
144
|
+
anchorY: clamp(y, top + 4, top + h - 4),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return placed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Callouts({ pins, pageView, active, onActivate, onOpenFile, maxSelf }) {
|
|
152
|
+
const gutter = Math.max(MIN_GUTTER, pageView.width * GUTTER_RATIO);
|
|
153
|
+
const totalWidth = pageView.width + gutter * 2;
|
|
154
|
+
const placed = useMemo(
|
|
155
|
+
() => placeMarkers(pins, pageView, gutter),
|
|
156
|
+
[pins, pageView, gutter],
|
|
157
|
+
);
|
|
158
|
+
const pctX = v => `${(v / totalWidth) * 100}%`;
|
|
159
|
+
const pctY = v => `${(v / pageView.height) * 100}%`;
|
|
160
|
+
const activePlace = active ? placed.get(active.key) : null;
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
className="page-frame"
|
|
164
|
+
style={{ aspectRatio: `${totalWidth} / ${pageView.height}` }}
|
|
165
|
+
>
|
|
166
|
+
<img
|
|
167
|
+
src={`/api/reports/${encodeURIComponent(pageView.reportId)}/page.jpg`}
|
|
168
|
+
alt="The profiled page when recording stopped"
|
|
169
|
+
style={{ left: pctX(gutter), width: pctX(pageView.width) }}
|
|
170
|
+
onError={pageView.onImageError}
|
|
171
|
+
/>
|
|
172
|
+
<svg
|
|
173
|
+
className="callout-svg"
|
|
174
|
+
viewBox={`0 0 ${totalWidth} ${pageView.height}`}
|
|
175
|
+
preserveAspectRatio="none"
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
>
|
|
178
|
+
{pins.map(pin => {
|
|
179
|
+
const at = placed.get(pin.key);
|
|
180
|
+
const { x, y, w, h } = pin.rect;
|
|
181
|
+
const cls = `sev-${severityOf(pin.c, maxSelf)} ${
|
|
182
|
+
active?.key === pin.key ? 'active' : ''
|
|
183
|
+
}`;
|
|
184
|
+
return (
|
|
185
|
+
<g key={pin.key} className={`callout ${cls}`}>
|
|
186
|
+
<rect x={gutter + x} y={y} width={w} height={h} rx="4" />
|
|
187
|
+
<line
|
|
188
|
+
x1={at.markerX}
|
|
189
|
+
y1={at.markerY}
|
|
190
|
+
x2={at.anchorX}
|
|
191
|
+
y2={at.anchorY}
|
|
192
|
+
/>
|
|
193
|
+
<circle cx={at.anchorX} cy={at.anchorY} r="4" />
|
|
194
|
+
</g>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</svg>
|
|
198
|
+
{pins.map(pin => {
|
|
199
|
+
const at = placed.get(pin.key);
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
key={pin.key}
|
|
203
|
+
type="button"
|
|
204
|
+
className={`marker sev-${severityOf(pin.c, maxSelf)} ${
|
|
205
|
+
active?.key === pin.key ? 'active' : ''
|
|
206
|
+
}`}
|
|
207
|
+
style={{ left: pctX(at.markerX), top: pctY(at.markerY) }}
|
|
208
|
+
aria-label={`${pin.n}. ${pin.c.name}, ${fmtMs(pin.c.selfMs)} self time`}
|
|
209
|
+
onMouseEnter={() => onActivate(pin)}
|
|
210
|
+
onMouseLeave={() => onActivate(null)}
|
|
211
|
+
onFocus={() => onActivate(pin)}
|
|
212
|
+
onBlur={() => onActivate(null)}
|
|
213
|
+
onClick={() => pin.c.file && onOpenFile(pin.c.file)}
|
|
214
|
+
>
|
|
215
|
+
{pin.n}
|
|
216
|
+
</button>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
{active && activePlace && (
|
|
220
|
+
<div
|
|
221
|
+
className="pin-card"
|
|
222
|
+
style={{
|
|
223
|
+
[activePlace.side]: pctX(gutter),
|
|
224
|
+
...(activePlace.markerY / pageView.height > 0.55
|
|
225
|
+
? {
|
|
226
|
+
bottom: `${100 - (activePlace.markerY / pageView.height) * 100}%`,
|
|
227
|
+
}
|
|
228
|
+
: { top: pctY(activePlace.markerY) }),
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<Details pin={active} />
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Screenshot of the page at the moment recording stopped, with the slowest
|
|
240
|
+
* React components outlined where they sit on screen. Hover or focus a pin for
|
|
241
|
+
* the file, render counts and a fix; click it to open the source.
|
|
242
|
+
*/
|
|
243
|
+
export default function PageMap({ report, hideVendor, onOpenFile }) {
|
|
244
|
+
const { pageView } = report;
|
|
245
|
+
const [activeKey, setActiveKey] = useState(null);
|
|
246
|
+
const [isImageMissing, setIsImageMissing] = useState(false);
|
|
247
|
+
const pins = useMemo(
|
|
248
|
+
() => (pageView ? buildPins(report, hideVendor) : []),
|
|
249
|
+
[report, pageView, hideVendor],
|
|
250
|
+
);
|
|
251
|
+
const maxSelf = Math.max(1, ...pins.map(p => p.c.selfMs));
|
|
252
|
+
const active = pins.find(p => p.key === activeKey) || null;
|
|
253
|
+
const frameView = useMemo(
|
|
254
|
+
() =>
|
|
255
|
+
pageView && {
|
|
256
|
+
...pageView,
|
|
257
|
+
reportId: report.id,
|
|
258
|
+
onImageError: () => setIsImageMissing(true),
|
|
259
|
+
},
|
|
260
|
+
[pageView, report.id],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (!pageView?.hasImage || isImageMissing)
|
|
264
|
+
return (
|
|
265
|
+
<div className="empty">
|
|
266
|
+
<b>No page map for this report</b>
|
|
267
|
+
<span>
|
|
268
|
+
It needs a development build of a React app and a recording made with
|
|
269
|
+
this version of the profiler. Older reports and production builds have
|
|
270
|
+
none.
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
if (!pins.length)
|
|
275
|
+
return (
|
|
276
|
+
<div className="empty">
|
|
277
|
+
<b>No slow components are visible on screen</b>
|
|
278
|
+
<span>
|
|
279
|
+
Nothing that rendered during the recording is currently on screen (or
|
|
280
|
+
everything is hidden by “Hide node_modules”).
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div className="page-map">
|
|
287
|
+
<div className="page-stage">
|
|
288
|
+
<p className="muted page-caption">
|
|
289
|
+
Screenshot taken when the recording stopped. Numbers in the margins
|
|
290
|
+
point to the components that spent the most time rendering and are on
|
|
291
|
+
screen now; hover a number for details.
|
|
292
|
+
</p>
|
|
293
|
+
<Callouts
|
|
294
|
+
pins={pins}
|
|
295
|
+
pageView={frameView}
|
|
296
|
+
active={active}
|
|
297
|
+
maxSelf={maxSelf}
|
|
298
|
+
onActivate={pin => setActiveKey(pin ? pin.key : null)}
|
|
299
|
+
onOpenFile={onOpenFile}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
<aside className="page-list" aria-label="Components on this page">
|
|
303
|
+
<h2>Hot spots on this page</h2>
|
|
304
|
+
<ol>
|
|
305
|
+
{pins.map(pin => (
|
|
306
|
+
<li key={pin.key}>
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
className={`hotspot ${active?.key === pin.key ? 'active' : ''}`}
|
|
310
|
+
onMouseEnter={() => setActiveKey(pin.key)}
|
|
311
|
+
onFocus={() => setActiveKey(pin.key)}
|
|
312
|
+
onClick={() => pin.c.file && onOpenFile(pin.c.file)}
|
|
313
|
+
>
|
|
314
|
+
<span className="pin-num">{pin.n}</span>
|
|
315
|
+
<span className="hotspot-main">
|
|
316
|
+
<b>{pin.c.name}</b>
|
|
317
|
+
<span className="muted mono">
|
|
318
|
+
{pin.c.file || 'file unknown'}
|
|
319
|
+
</span>
|
|
320
|
+
</span>
|
|
321
|
+
<span className="hotspot-time">{fmtMs(pin.c.selfMs)}</span>
|
|
322
|
+
</button>
|
|
323
|
+
</li>
|
|
324
|
+
))}
|
|
325
|
+
</ol>
|
|
326
|
+
</aside>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { ChevronIcon } from './Icons.jsx';
|
|
3
|
+
|
|
4
|
+
/** A button that opens a small floating panel; closes on outside click or Escape. */
|
|
5
|
+
export default function Popover({ label, icon, align = 'right', children }) {
|
|
6
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
7
|
+
const rootRef = useRef(null);
|
|
8
|
+
const panelId = useId();
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!isOpen) return undefined;
|
|
12
|
+
const onPointerDown = e => {
|
|
13
|
+
if (!rootRef.current?.contains(e.target)) setIsOpen(false);
|
|
14
|
+
};
|
|
15
|
+
const onKeyDown = e => {
|
|
16
|
+
if (e.key === 'Escape') setIsOpen(false);
|
|
17
|
+
};
|
|
18
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
19
|
+
document.addEventListener('keydown', onKeyDown);
|
|
20
|
+
return () => {
|
|
21
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
22
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
23
|
+
};
|
|
24
|
+
}, [isOpen]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<span className="popover" ref={rootRef}>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className="ghost"
|
|
31
|
+
aria-expanded={isOpen}
|
|
32
|
+
aria-controls={isOpen ? panelId : undefined}
|
|
33
|
+
onClick={() => setIsOpen(open => !open)}
|
|
34
|
+
>
|
|
35
|
+
{icon}
|
|
36
|
+
<span>{label}</span>
|
|
37
|
+
<ChevronIcon />
|
|
38
|
+
</button>
|
|
39
|
+
{isOpen && (
|
|
40
|
+
<div
|
|
41
|
+
id={panelId}
|
|
42
|
+
className={`popover-panel align-${align}`}
|
|
43
|
+
role="dialog"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|