@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-BvBsPok5.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +551 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +750 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +689 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +118 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +200 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/TimeBreakdown.jsx +45 -0
  65. package/src/components/TraceButton.jsx +76 -0
  66. package/src/components/TreeView.jsx +173 -0
  67. package/src/components/Vitals.jsx +163 -0
  68. package/src/culprits.js +127 -0
  69. package/src/download.js +18 -0
  70. package/src/format.js +26 -0
  71. package/src/heat.js +177 -0
  72. package/src/highlight.js +45 -0
  73. package/src/hooks/useCompare.js +187 -0
  74. package/src/hooks/useSession.js +73 -0
  75. package/src/layout.js +55 -0
  76. package/src/main.jsx +15 -0
  77. package/src/styles.css +2478 -0
  78. package/src/theme.js +75 -0
  79. package/src/themes.css +286 -0
  80. 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}()&nbsp;
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
+ }