@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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-based "culprits": turns the measured report into a ranked list of
|
|
3
|
+
* concrete problems, each with evidence, the exact code responsible
|
|
4
|
+
* (`culprits`: function + file + line) and a suggested fix.
|
|
5
|
+
* `impactMs` is what the ranking sorts on within a severity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
9
|
+
|
|
10
|
+
const fmt = ms => (ms >= 100 ? `${Math.round(ms)}ms` : `${ms.toFixed(1)}ms`);
|
|
11
|
+
const where = (file, line) =>
|
|
12
|
+
file ? `${file}${line ? `:${line}` : ''}` : 'unknown location';
|
|
13
|
+
|
|
14
|
+
/** One exact piece of code to blame. `lineKind` is 'exact' (source map) or 'located' (found by name). */
|
|
15
|
+
const culprit = (fn, file, line, lineKind, abs, ms, note = '') => ({
|
|
16
|
+
fn,
|
|
17
|
+
file,
|
|
18
|
+
line,
|
|
19
|
+
lineKind,
|
|
20
|
+
abs,
|
|
21
|
+
ms,
|
|
22
|
+
note,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const fromFunction = f =>
|
|
26
|
+
culprit(f.name, f.file, f.line, f.lineKind, f.abs, f.ms ?? f.selfMs);
|
|
27
|
+
|
|
28
|
+
export function buildFindings(report) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const add = f =>
|
|
31
|
+
out.push({ severity: 'warning', impactMs: 0, culprits: [], ...f });
|
|
32
|
+
const {
|
|
33
|
+
vitals,
|
|
34
|
+
files,
|
|
35
|
+
longTasks,
|
|
36
|
+
forcedReflows,
|
|
37
|
+
react,
|
|
38
|
+
network,
|
|
39
|
+
memory,
|
|
40
|
+
totals,
|
|
41
|
+
} = report;
|
|
42
|
+
const appFiles = files.filter(f => !f.vendor);
|
|
43
|
+
const activeMs = totals.activeMs || 1;
|
|
44
|
+
// Ratios are meaningless when almost nothing ran.
|
|
45
|
+
const busyEnough = totals.activeMs >= 500;
|
|
46
|
+
|
|
47
|
+
// Profiling an error page is the most common way to get a meaningless report.
|
|
48
|
+
if (report.meta.navStatus >= 400) {
|
|
49
|
+
add({
|
|
50
|
+
id: 'page-failed',
|
|
51
|
+
severity: 'critical',
|
|
52
|
+
category: 'Setup',
|
|
53
|
+
title: `The page itself failed to load (HTTP ${report.meta.navStatus}) — this recording profiled an error page`,
|
|
54
|
+
detail: `${report.meta.navUrl}. Results below describe the error page, not your app. A redirect to a login page that errors usually means the session is not signed in.`,
|
|
55
|
+
pinned: 2,
|
|
56
|
+
fix: 'Sign in (in the Chrome window this tool opened, or by opening your app’s login URL as the page to profile), check that the page renders, then record again.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A quiet recording explains an empty report better than a list of trivia.
|
|
61
|
+
const commits = react.commits?.count ?? 0;
|
|
62
|
+
const quiet = totals.activeMs < 300 && commits < 5 && longTasks.length === 0;
|
|
63
|
+
if (quiet && report.meta.mode === 'live' && report.meta.durationMs >= 2000) {
|
|
64
|
+
add({
|
|
65
|
+
id: 'recording-idle',
|
|
66
|
+
severity: 'warning',
|
|
67
|
+
category: 'Setup',
|
|
68
|
+
title: `Almost nothing ran during this ${(report.meta.durationMs / 1000).toFixed(0)} s recording`,
|
|
69
|
+
detail: `Only ${fmt(totals.activeMs)} of JavaScript executed${react.detected ? ` and React committed ${commits} times` : ''}, so there is nothing slow to point at.`,
|
|
70
|
+
pinned: 1,
|
|
71
|
+
fix: 'Press Record, then use the page (open a dashboard, scroll, type, switch tabs) before you press Stop — or use “Record page load” to profile startup.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ----- long tasks -------------------------------------------------------
|
|
76
|
+
longTasks.slice(0, 6).forEach((t, i) => {
|
|
77
|
+
const top = t.topFunctions.find(f => !f.file.startsWith('('));
|
|
78
|
+
add({
|
|
79
|
+
id: `long-task-${i}`,
|
|
80
|
+
severity: t.durMs >= 200 ? 'critical' : 'warning',
|
|
81
|
+
category: 'Main thread',
|
|
82
|
+
title: `${fmt(t.durMs)} long task at ${(t.startMs / 1000).toFixed(2)}s (${t.trigger.type})`,
|
|
83
|
+
detail:
|
|
84
|
+
`Blocked input for ${fmt(t.blockingMs)}. ` +
|
|
85
|
+
`${Object.entries(t.breakdown)
|
|
86
|
+
.filter(([, v]) => v >= 1)
|
|
87
|
+
.sort((a, b) => b[1] - a[1])
|
|
88
|
+
.slice(0, 3)
|
|
89
|
+
.map(([k, v]) => `${k} ${fmt(v)}`)
|
|
90
|
+
.join(', ')}.` +
|
|
91
|
+
(top
|
|
92
|
+
? ` Most time in ${top.name}() — ${where(top.file, top.line)} (${fmt(top.ms)}).`
|
|
93
|
+
: ' No JavaScript was sampled inside it (native browser work).'),
|
|
94
|
+
file: top?.file,
|
|
95
|
+
culprits: t.topFunctions
|
|
96
|
+
.filter(f => !f.file.startsWith('('))
|
|
97
|
+
.slice(0, 3)
|
|
98
|
+
.map(fromFunction),
|
|
99
|
+
impactMs: t.blockingMs,
|
|
100
|
+
fix: 'Break the work up (chunk it, defer with requestIdleCallback/startTransition), memoize the expensive computation, or move it off the main thread.',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
if (vitals.tbtMs >= 200) {
|
|
104
|
+
add({
|
|
105
|
+
id: 'tbt',
|
|
106
|
+
severity: vitals.tbtMs >= 600 ? 'critical' : 'warning',
|
|
107
|
+
category: 'Main thread',
|
|
108
|
+
title: `Total blocking time ${fmt(vitals.tbtMs)} across ${vitals.longTaskCount} long tasks`,
|
|
109
|
+
detail: `Longest task ${fmt(vitals.maxLongTaskMs)}. Anything over 200ms of TBT makes the page feel unresponsive.`,
|
|
110
|
+
culprits: (vitals.tbtCulprits || []).map(fromFunction),
|
|
111
|
+
impactMs: vitals.tbtMs,
|
|
112
|
+
fix: 'Start with the long tasks listed above, biggest first.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ----- hot code ---------------------------------------------------------
|
|
117
|
+
appFiles
|
|
118
|
+
.filter(
|
|
119
|
+
f =>
|
|
120
|
+
f.selfMs >= 80 ||
|
|
121
|
+
(busyEnough && f.selfMs >= 30 && f.selfMs / activeMs >= 0.1),
|
|
122
|
+
)
|
|
123
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
124
|
+
.slice(0, 5)
|
|
125
|
+
.forEach(f => {
|
|
126
|
+
const fn = f.functions[0];
|
|
127
|
+
add({
|
|
128
|
+
id: `hot-file-${f.path}`,
|
|
129
|
+
severity: f.selfMs >= 300 ? 'critical' : 'warning',
|
|
130
|
+
category: 'CPU',
|
|
131
|
+
title: `${f.path} spent ${fmt(f.selfMs)} of JavaScript (${Math.round((f.selfMs / activeMs) * 100)}% of CPU)`,
|
|
132
|
+
detail: fn
|
|
133
|
+
? `Hottest function: ${fn.name}() — ${where(f.path, fn.line)} (${fmt(fn.selfMs)} self).`
|
|
134
|
+
: '',
|
|
135
|
+
file: f.path,
|
|
136
|
+
culprits: f.functions
|
|
137
|
+
.slice(0, 3)
|
|
138
|
+
.map(fn =>
|
|
139
|
+
culprit(fn.name, f.path, fn.line, fn.lineKind, fn.abs, fn.selfMs),
|
|
140
|
+
),
|
|
141
|
+
impactMs: f.selfMs,
|
|
142
|
+
fix: 'Look for work repeated on every render/event, loops over large data, or JSON/regex/sort work that can be cached, memoized or moved to a worker.',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
files
|
|
146
|
+
.filter(
|
|
147
|
+
f =>
|
|
148
|
+
f.vendor &&
|
|
149
|
+
(f.selfMs >= 100 || (busyEnough && f.selfMs / activeMs >= 0.12)),
|
|
150
|
+
)
|
|
151
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
152
|
+
.slice(0, 3)
|
|
153
|
+
.forEach(f => {
|
|
154
|
+
const drivers = (report.vendorDrivers || {})[f.pkg] || [];
|
|
155
|
+
add({
|
|
156
|
+
id: `hot-vendor-${f.pkg}`,
|
|
157
|
+
severity: 'info',
|
|
158
|
+
category: 'CPU',
|
|
159
|
+
title: `Library ${f.pkg} used ${fmt(f.selfMs)} of CPU`,
|
|
160
|
+
detail: drivers.length
|
|
161
|
+
? `Driven mostly by ${drivers[0].fn}() — ${where(drivers[0].file, drivers[0].line)}. Libraries are rarely slow by themselves; the cost comes from how often and with how much data the app calls them.`
|
|
162
|
+
: 'Libraries are rarely slow by themselves; the cost comes from how often and with how much data the app calls them.',
|
|
163
|
+
file: drivers[0]?.file || f.path,
|
|
164
|
+
culprits: drivers.map(d =>
|
|
165
|
+
culprit(
|
|
166
|
+
d.fn,
|
|
167
|
+
d.file,
|
|
168
|
+
d.line,
|
|
169
|
+
d.lineKind,
|
|
170
|
+
d.abs,
|
|
171
|
+
d.ms,
|
|
172
|
+
`calls into ${f.pkg}`,
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
impactMs: f.selfMs,
|
|
176
|
+
fix: 'Reduce how often the calling code runs or how much data it passes in (memoize, debounce, paginate).',
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ----- forced reflow ----------------------------------------------------
|
|
181
|
+
const byBlame = new Map();
|
|
182
|
+
for (const f of forcedReflows) {
|
|
183
|
+
const key = `${f.file}|${f.fn}|${f.line}`;
|
|
184
|
+
const agg = byBlame.get(key) || { ...f, count: 0, ms: 0 };
|
|
185
|
+
agg.count += f.count;
|
|
186
|
+
agg.ms += f.ms;
|
|
187
|
+
byBlame.set(key, agg);
|
|
188
|
+
}
|
|
189
|
+
[...byBlame.values()]
|
|
190
|
+
.filter(f => f.ms >= 5 || f.count >= 10)
|
|
191
|
+
.sort((a, b) => b.ms - a.ms)
|
|
192
|
+
.slice(0, 5)
|
|
193
|
+
.forEach(f => {
|
|
194
|
+
add({
|
|
195
|
+
id: `forced-${f.file}-${f.fn}`,
|
|
196
|
+
severity: f.ms >= 50 ? 'critical' : 'warning',
|
|
197
|
+
category: 'Layout',
|
|
198
|
+
title: `Forced reflow in ${f.fn || 'an unknown function'} — ${f.count}× for ${fmt(f.ms)}`,
|
|
199
|
+
detail: `${where(f.file, f.line)} reads layout (offsetHeight, getBoundingClientRect, scrollTop…) after the DOM was changed, forcing the browser to recalculate layout synchronously.`,
|
|
200
|
+
file: f.file,
|
|
201
|
+
culprits: [
|
|
202
|
+
culprit(
|
|
203
|
+
f.fn,
|
|
204
|
+
f.file,
|
|
205
|
+
f.line,
|
|
206
|
+
f.lineKind,
|
|
207
|
+
f.abs,
|
|
208
|
+
f.ms,
|
|
209
|
+
f.caller ? `called from ${f.caller.fn}()` : '',
|
|
210
|
+
),
|
|
211
|
+
],
|
|
212
|
+
impactMs: f.ms,
|
|
213
|
+
fix: 'Batch reads before writes, read layout once and reuse it, or use ResizeObserver/IntersectionObserver instead of measuring in handlers.',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
if (report.thrashTasks > 0) {
|
|
217
|
+
add({
|
|
218
|
+
id: 'layout-thrash',
|
|
219
|
+
severity: 'critical',
|
|
220
|
+
category: 'Layout',
|
|
221
|
+
title: `Layout thrashing in ${report.thrashTasks} task${report.thrashTasks > 1 ? 's' : ''}`,
|
|
222
|
+
detail:
|
|
223
|
+
'Several forced layouts happened inside a single task: reads and writes of layout are interleaved.',
|
|
224
|
+
impactMs: forcedReflows.reduce((s, f) => s + f.ms, 0),
|
|
225
|
+
fix: 'Separate the read phase from the write phase (measure everything first, then mutate).',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ----- React ------------------------------------------------------------
|
|
230
|
+
const componentCulprit = (c, ms, note) =>
|
|
231
|
+
culprit(c.name, c.file, c.line, c.lineKind, c.abs, ms, note);
|
|
232
|
+
if (react.detected && react.hasTimings) {
|
|
233
|
+
react.components
|
|
234
|
+
.filter(c => c.wasted >= 5 && c.wasted / c.renders >= 0.3)
|
|
235
|
+
.sort((a, b) => b.wastedMs - a.wastedMs || b.wasted - a.wasted)
|
|
236
|
+
.slice(0, 6)
|
|
237
|
+
.forEach(c => {
|
|
238
|
+
const unstable = c.unstableProps[0];
|
|
239
|
+
add({
|
|
240
|
+
id: `wasted-${c.name}-${c.file}`,
|
|
241
|
+
severity:
|
|
242
|
+
c.wastedMs >= 30 || c.wasted >= 100 ? 'critical' : 'warning',
|
|
243
|
+
category: 'React',
|
|
244
|
+
title: `${c.name} re-rendered ${c.renders}× — ${c.wasted} with nothing changed (${Math.round((c.wasted / c.renders) * 100)}%)`,
|
|
245
|
+
detail:
|
|
246
|
+
`${where(c.file, c.line)}. ${fmt(c.wastedMs)} spent on renders whose props, state and context were identical.` +
|
|
247
|
+
(c.topParent
|
|
248
|
+
? ` Usually re-rendered by its parent ${c.topParent}.`
|
|
249
|
+
: ''),
|
|
250
|
+
file: c.file,
|
|
251
|
+
culprits: [
|
|
252
|
+
componentCulprit(
|
|
253
|
+
c,
|
|
254
|
+
c.wastedMs,
|
|
255
|
+
c.topParent
|
|
256
|
+
? `re-rendered by parent ${c.topParent}`
|
|
257
|
+
: 'component',
|
|
258
|
+
),
|
|
259
|
+
],
|
|
260
|
+
impactMs: c.wastedMs,
|
|
261
|
+
fix: unstable
|
|
262
|
+
? `Wrap in React.memo, and stabilise the props that keep changing identity (${unstable.prop}: ${unstable.kind}).`
|
|
263
|
+
: 'Wrap in React.memo, or stop the parent re-rendering (move state down, split the component).',
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
react.components
|
|
267
|
+
.flatMap(c => c.unstableProps.map(p => ({ c, p })))
|
|
268
|
+
.filter(({ p }) => p.count >= 8)
|
|
269
|
+
.sort((a, b) => b.p.count - a.p.count)
|
|
270
|
+
.slice(0, 6)
|
|
271
|
+
.forEach(({ c, p }) => {
|
|
272
|
+
add({
|
|
273
|
+
id: `unstable-${c.name}-${p.prop}`,
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
category: 'React',
|
|
276
|
+
title: `${c.name} gets a new ${p.kind === 'function' ? 'function' : 'object'} for “${p.prop}” on every render (${p.count}×)`,
|
|
277
|
+
detail: `${where(c.file, c.line)}. The value is recreated with ${p.kind === 'function' ? 'identical code' : 'identical contents'} but a new identity, which defeats React.memo and re-triggers effects that depend on it. Look at where ${c.topParent || 'the parent'} passes “${p.prop}”.`,
|
|
278
|
+
file: c.file,
|
|
279
|
+
culprits: [
|
|
280
|
+
componentCulprit(
|
|
281
|
+
c,
|
|
282
|
+
c.totalMs * (p.count / Math.max(c.renders, 1)),
|
|
283
|
+
`receives unstable prop “${p.prop}”`,
|
|
284
|
+
),
|
|
285
|
+
],
|
|
286
|
+
impactMs: c.totalMs * (p.count / Math.max(c.renders, 1)),
|
|
287
|
+
fix:
|
|
288
|
+
p.kind === 'function'
|
|
289
|
+
? `Create “${p.prop}” with useCallback (or hoist it out of the parent).`
|
|
290
|
+
: `Create “${p.prop}” with useMemo, or hoist the constant out of the component.`,
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
react.components
|
|
294
|
+
.filter(
|
|
295
|
+
c => c.selfMs >= 100 || (c.renders >= 5 && c.selfMs / c.renders >= 12),
|
|
296
|
+
)
|
|
297
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
298
|
+
.slice(0, 5)
|
|
299
|
+
.forEach(c => {
|
|
300
|
+
add({
|
|
301
|
+
id: `slow-component-${c.name}-${c.file}`,
|
|
302
|
+
severity: c.selfMs >= 300 ? 'critical' : 'warning',
|
|
303
|
+
category: 'React',
|
|
304
|
+
title: `${c.name} is slow to render: ${fmt(c.selfMs)} total, ${fmt(c.selfMs / c.renders)} per render (${c.renders}×)`,
|
|
305
|
+
detail: `${where(c.file, c.line)}. Self time excludes its children.`,
|
|
306
|
+
file: c.file,
|
|
307
|
+
culprits: [componentCulprit(c, c.selfMs, 'slow render')],
|
|
308
|
+
impactMs: c.selfMs,
|
|
309
|
+
fix: 'Move expensive calculations into useMemo, virtualise long lists, and avoid deriving large data structures inside render.',
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
react.components
|
|
313
|
+
.filter(
|
|
314
|
+
c =>
|
|
315
|
+
c.renders >= 60 &&
|
|
316
|
+
c.renders / Math.max(report.meta.durationMs / 1000, 1) >= 10,
|
|
317
|
+
)
|
|
318
|
+
.sort((a, b) => b.renders - a.renders)
|
|
319
|
+
.slice(0, 3)
|
|
320
|
+
.forEach(c => {
|
|
321
|
+
add({
|
|
322
|
+
id: `frequent-${c.name}-${c.file}`,
|
|
323
|
+
severity: 'warning',
|
|
324
|
+
category: 'React',
|
|
325
|
+
title: `${c.name} rendered ${c.renders}× (${(c.renders / (report.meta.durationMs / 1000)).toFixed(0)}/s)`,
|
|
326
|
+
detail: `${where(c.file, c.line)}. Reasons: ${
|
|
327
|
+
Object.entries(c.reasons)
|
|
328
|
+
.filter(([, n]) => n)
|
|
329
|
+
.map(([k, n]) => `${k} ${n}`)
|
|
330
|
+
.join(', ') || 'mount'
|
|
331
|
+
}.`,
|
|
332
|
+
file: c.file,
|
|
333
|
+
culprits: [componentCulprit(c, c.selfMs, 'renders very often')],
|
|
334
|
+
impactMs: c.selfMs,
|
|
335
|
+
fix: 'Find what updates this often (a timer, a store subscription, a scroll handler) and throttle it or narrow the selector/subscription.',
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
react.slowCommits.slice(0, 3).forEach((c, i) => {
|
|
339
|
+
if (c.dur < 50) return;
|
|
340
|
+
add({
|
|
341
|
+
id: `commit-${i}`,
|
|
342
|
+
severity: c.dur >= 100 ? 'critical' : 'warning',
|
|
343
|
+
category: 'React',
|
|
344
|
+
title: `React commit took ${fmt(c.dur)} (${c.rendered} components rendered, ${c.wasted} wasted)`,
|
|
345
|
+
detail: `At ${(c.tMs / 1000).toFixed(2)}s.`,
|
|
346
|
+
culprits: react.components
|
|
347
|
+
.slice(0, 3)
|
|
348
|
+
.map(k =>
|
|
349
|
+
componentCulprit(k, k.selfMs, 'slowest components overall'),
|
|
350
|
+
),
|
|
351
|
+
impactMs: c.dur,
|
|
352
|
+
fix: 'Look for a high component count: a state change near the root of a large tree. Lift state down or split context.',
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
} else if (react.detected && !react.hasTimings) {
|
|
356
|
+
add({
|
|
357
|
+
id: 'react-prod',
|
|
358
|
+
severity: 'info',
|
|
359
|
+
category: 'React',
|
|
360
|
+
title: 'React is a production build: render timings are unavailable',
|
|
361
|
+
detail:
|
|
362
|
+
'Render counts and reasons are still collected where possible, but durations need a development or profiling build.',
|
|
363
|
+
fix: 'Profile against the dev server (npm run start / webpack-dev-server).',
|
|
364
|
+
});
|
|
365
|
+
} else if (!react.detected) {
|
|
366
|
+
add({
|
|
367
|
+
id: 'react-missing',
|
|
368
|
+
severity: 'info',
|
|
369
|
+
category: 'React',
|
|
370
|
+
title: 'No React renderer was seen on this page',
|
|
371
|
+
detail:
|
|
372
|
+
'The probe must be installed before React loads. Use “Reload & record” (or Reload after connecting) so it is injected first.',
|
|
373
|
+
fix: 'Click “Record page load”, or reload the tab from this tool.',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ----- layout shifts / interactions / vitals ----------------------------
|
|
378
|
+
if (vitals.cls >= 0.1) {
|
|
379
|
+
const worst = vitals.shifts[0];
|
|
380
|
+
add({
|
|
381
|
+
id: 'cls',
|
|
382
|
+
severity: vitals.cls >= 0.25 ? 'critical' : 'warning',
|
|
383
|
+
category: 'Visual stability',
|
|
384
|
+
title: `Cumulative layout shift ${vitals.cls.toFixed(3)}`,
|
|
385
|
+
detail: worst
|
|
386
|
+
? `Biggest shift (${worst.value.toFixed(3)}) moved ${worst.sources.join(', ') || 'unknown elements'}.`
|
|
387
|
+
: '',
|
|
388
|
+
impactMs: vitals.cls * 1000,
|
|
389
|
+
fix: 'Reserve space (width/height, min-height, skeletons) for content that loads late, and avoid inserting content above existing content.',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
report.interactions.slice(0, 3).forEach((e, i) => {
|
|
393
|
+
if (e.durMs < 200) return;
|
|
394
|
+
add({
|
|
395
|
+
id: `interaction-${i}`,
|
|
396
|
+
severity: e.durMs >= 500 ? 'critical' : 'warning',
|
|
397
|
+
category: 'Responsiveness',
|
|
398
|
+
title: `Slow ${e.type} on ${e.target || 'page'}: ${fmt(e.durMs)}`,
|
|
399
|
+
detail: `Input delay ${fmt(e.inputDelayMs)}, handler ${fmt(e.processingMs)}, next paint ${fmt(e.presentMs)}.`,
|
|
400
|
+
culprits: (vitals.tbtCulprits || []).slice(0, 2).map(fromFunction),
|
|
401
|
+
impactMs: e.durMs,
|
|
402
|
+
fix:
|
|
403
|
+
e.processingMs >= e.inputDelayMs && e.processingMs >= e.presentMs
|
|
404
|
+
? 'The handler itself is slow: profile it in Call tree.'
|
|
405
|
+
: 'The main thread was busy (input delay) or rendering after the handler was slow (presentation delay).',
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
if (vitals.lcpMs && vitals.lcpMs > 2500) {
|
|
409
|
+
add({
|
|
410
|
+
id: 'lcp',
|
|
411
|
+
severity: vitals.lcpMs > 4000 ? 'critical' : 'warning',
|
|
412
|
+
category: 'Loading',
|
|
413
|
+
title: `Largest Contentful Paint ${(vitals.lcpMs / 1000).toFixed(2)}s`,
|
|
414
|
+
detail: vitals.lcpElement ? `LCP element: ${vitals.lcpElement}.` : '',
|
|
415
|
+
culprits: (report.hotFunctions || [])
|
|
416
|
+
.filter(f => !f.vendor)
|
|
417
|
+
.slice(0, 3)
|
|
418
|
+
.map(f =>
|
|
419
|
+
culprit(
|
|
420
|
+
f.name,
|
|
421
|
+
f.file,
|
|
422
|
+
f.line,
|
|
423
|
+
f.lineKind,
|
|
424
|
+
f.abs,
|
|
425
|
+
f.selfMs,
|
|
426
|
+
'hottest app function during load',
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
impactMs: vitals.lcpMs,
|
|
430
|
+
fix: 'Reduce render-blocking JS/CSS, split the main bundle, and avoid fetching data before the first meaningful content can render.',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (vitals.fcpMs && vitals.fcpMs > 1800) {
|
|
434
|
+
add({
|
|
435
|
+
id: 'fcp',
|
|
436
|
+
severity: vitals.fcpMs > 3000 ? 'critical' : 'warning',
|
|
437
|
+
category: 'Loading',
|
|
438
|
+
title: `First Contentful Paint ${(vitals.fcpMs / 1000).toFixed(2)}s`,
|
|
439
|
+
detail: '',
|
|
440
|
+
impactMs: vitals.fcpMs,
|
|
441
|
+
fix: 'Reduce the JavaScript that must run before first paint.',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ----- time spent outside app code -------------------------------------
|
|
446
|
+
if (busyEnough && totals.gcMs / activeMs >= 0.1 && totals.gcMs >= 50) {
|
|
447
|
+
add({
|
|
448
|
+
id: 'gc',
|
|
449
|
+
severity: 'warning',
|
|
450
|
+
category: 'Memory',
|
|
451
|
+
title: `Garbage collection took ${fmt(totals.gcMs)} (${Math.round((totals.gcMs / activeMs) * 100)}% of CPU)`,
|
|
452
|
+
detail:
|
|
453
|
+
'Heavy allocation churn: many short-lived objects or arrays created per render/event.',
|
|
454
|
+
culprits: (report.hotFunctions || [])
|
|
455
|
+
.filter(f => !f.vendor)
|
|
456
|
+
.slice(0, 3)
|
|
457
|
+
.map(f =>
|
|
458
|
+
culprit(
|
|
459
|
+
f.name,
|
|
460
|
+
f.file,
|
|
461
|
+
f.line,
|
|
462
|
+
f.lineKind,
|
|
463
|
+
f.abs,
|
|
464
|
+
f.selfMs,
|
|
465
|
+
'busiest app function (likely allocator)',
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
impactMs: totals.gcMs,
|
|
469
|
+
fix: 'Avoid creating large intermediate arrays/objects in hot paths; reuse or memoize them.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (totals.compileMs >= 100) {
|
|
473
|
+
add({
|
|
474
|
+
id: 'compile',
|
|
475
|
+
severity: 'warning',
|
|
476
|
+
category: 'Loading',
|
|
477
|
+
title: `Script compile/parse took ${fmt(totals.compileMs)}`,
|
|
478
|
+
detail: 'Large JavaScript bundles cost CPU to parse before they run.',
|
|
479
|
+
impactMs: totals.compileMs,
|
|
480
|
+
fix: 'Code-split by route/feature and lazy-load heavy components.',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (memory.domNodes >= 1500) {
|
|
484
|
+
add({
|
|
485
|
+
id: 'dom-size',
|
|
486
|
+
severity: memory.domNodes >= 3000 ? 'critical' : 'warning',
|
|
487
|
+
category: 'Memory',
|
|
488
|
+
title: `Large DOM: ${memory.domNodes.toLocaleString()} nodes`,
|
|
489
|
+
detail: `${memory.listeners.toLocaleString()} event listeners. Style recalculation and layout cost grows with DOM size.`,
|
|
490
|
+
impactMs: memory.domNodes / 50,
|
|
491
|
+
fix: 'Virtualise long lists and tables, and unmount hidden tabs/modals instead of hiding them.',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (memory.heapGrowthMb >= 30) {
|
|
495
|
+
add({
|
|
496
|
+
id: 'heap-growth',
|
|
497
|
+
severity: memory.heapGrowthMb >= 100 ? 'critical' : 'warning',
|
|
498
|
+
category: 'Memory',
|
|
499
|
+
title: `JS heap grew ${memory.heapGrowthMb.toFixed(0)} MB during the recording`,
|
|
500
|
+
detail:
|
|
501
|
+
'Sustained growth across repeated actions indicates a leak (listeners, timers or caches never released).',
|
|
502
|
+
impactMs: memory.heapGrowthMb * 5,
|
|
503
|
+
fix: 'Repeat the action a few times and compare. Check for un-removed listeners, intervals and growing module-level caches.',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ----- network ----------------------------------------------------------
|
|
508
|
+
network.uncompressed.slice(0, 3).forEach((r, i) => {
|
|
509
|
+
add({
|
|
510
|
+
id: `uncompressed-${i}`,
|
|
511
|
+
severity: 'warning',
|
|
512
|
+
category: 'Network',
|
|
513
|
+
title: `${r.name} is served uncompressed (${(r.decoded / 1024).toFixed(0)} KB)`,
|
|
514
|
+
detail: r.url,
|
|
515
|
+
impactMs: r.decoded / 1024 / 4,
|
|
516
|
+
fix: 'Enable gzip/brotli for JS, CSS and JSON responses.',
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
network.large.slice(0, 3).forEach((r, i) => {
|
|
520
|
+
if (r.encoded < 500 * 1024) return;
|
|
521
|
+
add({
|
|
522
|
+
id: `large-${i}`,
|
|
523
|
+
severity: r.encoded > 2 * 1024 * 1024 ? 'critical' : 'warning',
|
|
524
|
+
category: 'Network',
|
|
525
|
+
title: `${r.name} is ${(r.encoded / 1024 / 1024).toFixed(2)} MB over the wire`,
|
|
526
|
+
detail: r.url,
|
|
527
|
+
impactMs: r.encoded / 1024 / 4,
|
|
528
|
+
fix: 'Split the bundle, drop unused dependencies, or lazy-load it.',
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
network.slow.slice(0, 3).forEach((r, i) => {
|
|
532
|
+
if (r.durMs < 1500) return;
|
|
533
|
+
add({
|
|
534
|
+
id: `slow-request-${i}`,
|
|
535
|
+
severity: r.durMs >= 4000 ? 'critical' : 'warning',
|
|
536
|
+
category: 'Network',
|
|
537
|
+
title: `${r.name} took ${(r.durMs / 1000).toFixed(2)}s`,
|
|
538
|
+
detail: r.url,
|
|
539
|
+
impactMs: r.durMs,
|
|
540
|
+
fix: 'Check the backend endpoint, add caching, or fetch it earlier / in parallel.',
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Setup problems (error page, idle recording) explain everything else, so they lead their severity.
|
|
545
|
+
return out.sort(
|
|
546
|
+
(a, b) =>
|
|
547
|
+
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
|
548
|
+
(b.pinned || 0) - (a.pinned || 0) ||
|
|
549
|
+
b.impactMs - a.impactMs,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds where a function is *defined* in its source file.
|
|
3
|
+
*
|
|
4
|
+
* Webpack dev bundles carry no source map in this repo, so the profiler only
|
|
5
|
+
* knows a function's name and its line in the bundle. Looking the name up in
|
|
6
|
+
* the real file gives the developer a line to jump to. The result is a
|
|
7
|
+
* best-effort match (flagged "located", not "exact").
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const escapeRe = name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
|
|
14
|
+
/** 'Foo.bar' -> 'bar', 'get x' -> 'x', '(anonymous in run)' -> 'run'; null when nothing searchable. */
|
|
15
|
+
export function searchableName(name) {
|
|
16
|
+
if (!name) return null;
|
|
17
|
+
const anon = /^\(anonymous in (.+)\)$/.exec(name);
|
|
18
|
+
const base = (anon ? anon[1] : name).replace(
|
|
19
|
+
/^(?:get|set|bound|async)\s+/,
|
|
20
|
+
'',
|
|
21
|
+
);
|
|
22
|
+
const last = base.split('.').pop();
|
|
23
|
+
return /^[A-Za-z_$][\w$]*$/.test(last) ? last : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function patterns(n) {
|
|
27
|
+
const id = escapeRe(n);
|
|
28
|
+
// Earlier entries are stronger evidence of a definition than later ones.
|
|
29
|
+
return [
|
|
30
|
+
new RegExp(`\\bfunction\\s*\\*?\\s*${id}\\s*[(<]`),
|
|
31
|
+
new RegExp(`\\bclass\\s+${id}\\b`),
|
|
32
|
+
new RegExp(`\\b(?:const|let|var)\\s+${id}\\s*(?::[^=]+)?=`),
|
|
33
|
+
new RegExp(
|
|
34
|
+
`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:(?:static|public|private|protected|readonly|get|set)\\s+)*${id}\\s*(?:<[^>]*>)?\\s*\\([^;]*\\{\\s*$`,
|
|
35
|
+
),
|
|
36
|
+
new RegExp(
|
|
37
|
+
`\\b${id}\\s*[:=]\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]+)?=>|[A-Za-z_$][\\w$]*\\s*=>)`,
|
|
38
|
+
),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {number | null} 1-based line of the most likely definition */
|
|
43
|
+
export function findDefinitionLine(source, name) {
|
|
44
|
+
const n = searchableName(name);
|
|
45
|
+
if (!n) return null;
|
|
46
|
+
const lines = source.split('\n');
|
|
47
|
+
const tests = patterns(n);
|
|
48
|
+
let best = null;
|
|
49
|
+
for (let i = 0; i < lines.length && best?.rank !== 0; i += 1) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
if (!line.includes(n)) continue;
|
|
52
|
+
for (let rank = 0; rank < tests.length; rank += 1) {
|
|
53
|
+
if (tests[rank].test(line)) {
|
|
54
|
+
if (!best || rank < best.rank) best = { rank, line: i + 1 };
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return best ? best.line : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Caches file reads; `roots` are searched in order (same as the source viewer). */
|
|
63
|
+
export class SourceIndex {
|
|
64
|
+
constructor(roots) {
|
|
65
|
+
this.roots = roots;
|
|
66
|
+
this.files = new Map();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
read(rel) {
|
|
70
|
+
if (this.files.has(rel)) return this.files.get(rel);
|
|
71
|
+
let entry = null;
|
|
72
|
+
if (!path.isAbsolute(rel) && !rel.split('/').includes('..')) {
|
|
73
|
+
for (const root of this.roots) {
|
|
74
|
+
try {
|
|
75
|
+
const abs = path.join(root, rel);
|
|
76
|
+
if (fs.statSync(abs).size <= 2 * 1024 * 1024) {
|
|
77
|
+
entry = { abs, text: fs.readFileSync(abs, 'utf8') };
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* try the next root */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.files.set(rel, entry);
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns {{line: number | null, abs: string} | null} */
|
|
90
|
+
lookup(file, name) {
|
|
91
|
+
const entry = this.read(file);
|
|
92
|
+
if (!entry) return null;
|
|
93
|
+
return { line: findDefinitionLine(entry.text, name), abs: entry.abs };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
absolutePath(file) {
|
|
97
|
+
return this.read(file)?.abs || null;
|
|
98
|
+
}
|
|
99
|
+
}
|