@progen-labs/spectra 0.0.0-stage → 0.1.1

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-D0Lxrmdt.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 +570 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +775 -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 +828 -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 +120 -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 +206 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/SpectraCulprits.jsx +39 -0
  65. package/src/components/TimeBreakdown.jsx +45 -0
  66. package/src/components/TraceButton.jsx +76 -0
  67. package/src/components/TreeView.jsx +173 -0
  68. package/src/components/Vitals.jsx +163 -0
  69. package/src/culprits.js +127 -0
  70. package/src/download.js +18 -0
  71. package/src/format.js +26 -0
  72. package/src/heat.js +177 -0
  73. package/src/highlight.js +45 -0
  74. package/src/hooks/useCompare.js +187 -0
  75. package/src/hooks/useSession.js +73 -0
  76. package/src/layout.js +55 -0
  77. package/src/main.jsx +15 -0
  78. package/src/styles.css +2478 -0
  79. package/src/theme.js +75 -0
  80. package/src/themes.css +286 -0
  81. package/vite.config.js +8 -0
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Joins the four data sources into one report:
3
+ * trace (main-thread work) + CPU samples (which code) + React probe
4
+ * (which components, why) + browser vitals (what users feel).
5
+ */
6
+ import {
7
+ buildCallTree,
8
+ analyzeCpuProfile,
9
+ extractCpuProfile,
10
+ nodeAt,
11
+ pathTo,
12
+ windowSelf,
13
+ } from './cpu.js';
14
+ import { buildFindings } from './findings.js';
15
+ import {
16
+ CATEGORIES,
17
+ LONG_TASK_US,
18
+ analyzeTrace,
19
+ findMainThread,
20
+ } from './trace.js';
21
+ import { packageOf } from './sources.js';
22
+ import path from 'node:path';
23
+ import { FRONTEND_ROOT } from '../config.js';
24
+
25
+ const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
26
+
27
+ /** Turn an absolute component file (e.g. Vue SFC __file) into a project path. */
28
+ function toProjectRelative(abs) {
29
+ try {
30
+ if (!abs || !path.isAbsolute(abs)) return abs || '';
31
+ const rel = path.relative(FRONTEND_ROOT, abs);
32
+ return rel.startsWith('..') ? abs : rel;
33
+ } catch {
34
+ return abs || '';
35
+ }
36
+ }
37
+ const ms = us => us / 1000;
38
+
39
+ function newFile(path, vendor, pkg) {
40
+ return {
41
+ path,
42
+ vendor,
43
+ pkg,
44
+ selfMs: 0,
45
+ totalMs: 0,
46
+ blockMs: 0,
47
+ reactMs: 0,
48
+ renders: 0,
49
+ wasted: 0,
50
+ forcedMs: 0,
51
+ forcedCount: 0,
52
+ functions: [],
53
+ components: [],
54
+ };
55
+ }
56
+
57
+ function fileEntry(map, loc) {
58
+ let f = map.get(loc.file);
59
+ if (!f) {
60
+ f = newFile(loc.file, loc.vendor, loc.pkg);
61
+ map.set(loc.file, f);
62
+ }
63
+ return f;
64
+ }
65
+
66
+ function pickBlameFrame(frames) {
67
+ return (
68
+ frames.find(f => !f.vendor && f.file !== '(native)') ||
69
+ frames.find(f => f.file !== '(native)') ||
70
+ frames[0] ||
71
+ null
72
+ );
73
+ }
74
+
75
+ const isAnonymous = name => !name || name === '(anonymous)';
76
+
77
+ /**
78
+ * Best line to show for a function. A source-mapped line is exact; otherwise the
79
+ * function's definition is looked up by name in the real file ("located").
80
+ */
81
+ function locate(sourceIndex, file, name, line, exact) {
82
+ const real = file && !file.startsWith('(') && !file.startsWith('bundle:');
83
+ const abs = real && sourceIndex ? sourceIndex.absolutePath(file) : null;
84
+ if (exact && line) return { line, lineKind: 'exact', abs };
85
+ const hit = real && sourceIndex ? sourceIndex.lookup(file, name) : null;
86
+ if (hit?.line) return { line: hit.line, lineKind: 'located', abs: hit.abs };
87
+ return { line: line ?? null, lineKind: line ? 'exact' : null, abs };
88
+ }
89
+
90
+ function collectScriptIds(events, profile, locations) {
91
+ const ids = new Set();
92
+ for (const n of profile?.nodes || [])
93
+ if (n.callFrame?.scriptId) ids.add(String(n.callFrame.scriptId));
94
+ for (const e of events) {
95
+ const stack = e.args?.beginData?.stackTrace || e.args?.data?.stackTrace;
96
+ if (stack)
97
+ for (const f of stack) if (f.scriptId) ids.add(String(f.scriptId));
98
+ }
99
+ for (const loc of locations.values())
100
+ if (loc?.scriptId) ids.add(String(loc.scriptId));
101
+ return ids;
102
+ }
103
+
104
+ /**
105
+ * @param {object} input
106
+ * @param {object[]} input.events raw trace events
107
+ * @param {object|null} input.probe __PERF_PROBE__.snapshot()
108
+ * @param {object} input.metrics {before, after, dom}
109
+ * @param {Map<number, {scriptId: string, lineNumber: number, columnNumber: number}>} input.locations component id -> function location
110
+ * @param {import('./sources.js').SourceResolver} input.resolver
111
+ * @param {object} input.meta
112
+ */
113
+ export async function buildReport({
114
+ events,
115
+ probe,
116
+ metrics,
117
+ locations,
118
+ resolver,
119
+ meta,
120
+ sourceIndex = null,
121
+ }) {
122
+ const thread = findMainThread(events);
123
+ if (!thread)
124
+ throw new Error(
125
+ 'No renderer main thread found in the trace (was the page idle or closed?).',
126
+ );
127
+
128
+ const trace = analyzeTrace(events, thread);
129
+ const profile = extractCpuProfile(events, thread.pid, thread.tid);
130
+ await resolver.prepare(collectScriptIds(events, profile, locations));
131
+
132
+ const cpu = profile
133
+ ? analyzeCpuProfile(profile, cf =>
134
+ resolver.resolve(
135
+ String(cf.scriptId),
136
+ cf.url,
137
+ cf.lineNumber,
138
+ cf.columnNumber,
139
+ cf.functionName,
140
+ ),
141
+ )
142
+ : null;
143
+ const origin = trace.firstTs;
144
+ const rel = us => ms(us - origin);
145
+ const files = new Map();
146
+
147
+ // ----- CPU per file / function ------------------------------------------
148
+ if (cpu) {
149
+ for (const f of cpu.files) {
150
+ if (f.file.startsWith('(')) continue;
151
+ const entry = fileEntry(files, f);
152
+ entry.selfMs += ms(f.selfUs);
153
+ entry.totalMs += ms(f.totalUs);
154
+ }
155
+ for (const fn of cpu.functions) {
156
+ if (fn.file.startsWith('(')) continue;
157
+ const entry = files.get(fn.file);
158
+ if (entry) {
159
+ entry.functions.push({
160
+ name: fn.name,
161
+ line: fn.line,
162
+ bundleLine: fn.bundleLine + 1,
163
+ exact: fn.exact,
164
+ selfMs: ms(fn.selfUs),
165
+ totalMs: ms(fn.totalUs),
166
+ });
167
+ }
168
+ }
169
+ for (const f of files.values()) {
170
+ f.functions = f.functions
171
+ .filter(fn => fn.selfMs >= 0.3 || fn.totalMs >= 3)
172
+ .sort((a, b) => b.selfMs - a.selfMs)
173
+ .slice(0, 30);
174
+ if (!f.vendor) {
175
+ f.functions = f.functions.map(fn => ({
176
+ ...fn,
177
+ ...locate(sourceIndex, f.path, fn.name, fn.line, fn.exact),
178
+ }));
179
+ }
180
+ }
181
+ }
182
+
183
+ // Starting the CPU profiler makes V8 index every function of a large app, which
184
+ // stalls the main thread at the very start of a live recording. It looks like a
185
+ // long task with no JavaScript in it; it is measurement overhead, not the app.
186
+ const jsUsIn = t => {
187
+ if (!cpu) return 0;
188
+ let sum = 0;
189
+ for (const [node, us] of windowSelf(cpu, t.ts, t.ts + t.durUs)) {
190
+ if (!node.native) sum += us;
191
+ }
192
+ return sum;
193
+ };
194
+ const isStartupArtifact = t =>
195
+ meta.mode === 'live' &&
196
+ rel(t.ts) < 500 &&
197
+ t.breakdown.other >= 0.8 * t.durUs &&
198
+ jsUsIn(t) < 0.1 * t.durUs;
199
+ const artifactTasks = new Set(trace.longTasks.filter(isStartupArtifact));
200
+ const artifactMs = ms([...artifactTasks].reduce((s, t) => s + t.durUs, 0));
201
+ const realLongTasks = trace.longTasks.filter(t => !artifactTasks.has(t));
202
+
203
+ // ----- long tasks (with blame from the samples inside them) ---------------
204
+ const longTasks = realLongTasks.slice(0, 40).map(t => {
205
+ const topFiles = new Map();
206
+ const topFns = new Map();
207
+ if (cpu) {
208
+ for (const [node, us] of windowSelf(cpu, t.ts, t.ts + t.durUs)) {
209
+ if (node.native) continue;
210
+ topFiles.set(node.loc.file, (topFiles.get(node.loc.file) || 0) + us);
211
+ const key = `${node.name}|${node.loc.file}|${node.loc.line ?? ''}`;
212
+ topFns.set(key, {
213
+ name: node.name,
214
+ file: node.loc.file,
215
+ line: node.loc.line,
216
+ exact: node.loc.exact,
217
+ ms: (topFns.get(key)?.ms || 0) + ms(us),
218
+ });
219
+ }
220
+ }
221
+ const blocking = ms(t.durUs) - ms(LONG_TASK_US);
222
+ for (const [file, us] of topFiles) {
223
+ const f = files.get(file);
224
+ if (f) f.blockMs += ms(us) * (blocking / ms(t.durUs));
225
+ }
226
+ return {
227
+ startMs: round(rel(t.ts)),
228
+ durMs: round(ms(t.durUs)),
229
+ blockingMs: round(blocking),
230
+ trigger: t.trigger,
231
+ breakdown: Object.fromEntries(
232
+ CATEGORIES.map(c => [c, round(ms(t.breakdown[c]))]),
233
+ ),
234
+ topFiles: [...topFiles]
235
+ .sort((a, b) => b[1] - a[1])
236
+ .slice(0, 5)
237
+ .map(([file, us]) => ({ file, ms: round(ms(us)) })),
238
+ topFunctions: [...topFns.values()]
239
+ .sort((a, b) => b.ms - a.ms)
240
+ .slice(0, 6)
241
+ .map(f => ({
242
+ name: f.name,
243
+ file: f.file,
244
+ ms: round(f.ms),
245
+ ...locate(sourceIndex, f.file, f.name, f.line, f.exact),
246
+ })),
247
+ forcedCount: t.forcedCount,
248
+ thrash: t.thrash,
249
+ };
250
+ });
251
+
252
+ // ----- forced reflow ----------------------------------------------------
253
+ const forcedReflows = trace.forced.map(f => {
254
+ let frames = [];
255
+ if (f.stack) {
256
+ frames = f.stack.map(fr => ({
257
+ ...resolver.resolve(
258
+ String(fr.scriptId),
259
+ fr.url,
260
+ fr.lineNumber,
261
+ fr.columnNumber,
262
+ fr.functionName,
263
+ ),
264
+ fnName: fr.functionName,
265
+ }));
266
+ } else if (cpu) {
267
+ const node = nodeAt(cpu, f.ts + f.durUs / 2);
268
+ if (node) {
269
+ frames = pathTo(node, cpu.nodes)
270
+ .reverse()
271
+ .map(n => ({ ...n.loc, fnName: n.name }));
272
+ }
273
+ }
274
+ const blame = pickBlameFrame(frames);
275
+ const caller = blame ? frames[frames.indexOf(blame) + 1] : null;
276
+ const callerName = caller?.name || caller?.fnName || '';
277
+ let fn = blame?.name || blame?.fnName || '';
278
+ if (isAnonymous(fn) && callerName) fn = `(anonymous in ${callerName})`;
279
+ return {
280
+ kind: f.kind,
281
+ ms: ms(f.durUs),
282
+ startMs: rel(f.ts),
283
+ file: blame?.file || '',
284
+ fn,
285
+ line: blame?.line ?? null,
286
+ exact: Boolean(blame?.exact),
287
+ caller: callerName
288
+ ? { fn: callerName, file: caller.file, line: caller.line ?? null }
289
+ : null,
290
+ trigger: f.scriptParent,
291
+ };
292
+ });
293
+ for (const f of forcedReflows) {
294
+ const entry = f.file && files.get(f.file);
295
+ if (entry) {
296
+ entry.forcedMs += f.ms;
297
+ entry.forcedCount += 1;
298
+ }
299
+ }
300
+
301
+ // ----- React ------------------------------------------------------------
302
+ const reactReport = summarizeReact(
303
+ probe,
304
+ locations,
305
+ resolver,
306
+ files,
307
+ meta.durationMs,
308
+ sourceIndex,
309
+ );
310
+
311
+ // ----- Framework-agnostic summary (Vue/Svelte/Angular/Next) ----------------
312
+ function summarizeFramework(probe) {
313
+ if (!probe || !probe.framework) return { detected: false };
314
+ const f = probe.framework;
315
+ return {
316
+ detected: !!f.detected,
317
+ name: f.name || 'unknown',
318
+ version: f.version || '',
319
+ commitCount: probe.commitCount || 0,
320
+ components: f.components || [],
321
+ };
322
+ }
323
+ const frameworkReport = summarizeFramework(probe);
324
+
325
+ // ----- vitals -----------------------------------------------------------
326
+ const v = probe?.vitals;
327
+ const tbtMs = realLongTasks.reduce((s, t) => s + (ms(t.durUs) - 50), 0);
328
+ const tbtCulprits = mergeFunctions(longTasks);
329
+ const loadMode = meta.mode === 'load';
330
+ const shifts = (v?.shifts || []).sort((a, b) => b.value - a.value);
331
+ const interactions = summarizeInteractions(v?.events || []);
332
+ const m = metrics;
333
+ const delta = key => (m.after[key] ?? 0) - (m.before[key] ?? 0);
334
+ const activeMs = cpu ? ms(cpu.totals.activeUs) : ms(trace.busyUs);
335
+
336
+ const network = summarizeNetwork(
337
+ probe?.resources || [],
338
+ meta.mode === 'load' ? 0 : probe?.since || 0,
339
+ );
340
+
341
+ const report = {
342
+ id: meta.id,
343
+ createdAt: meta.createdAt,
344
+ meta: {
345
+ url: meta.url,
346
+ title: meta.title,
347
+ mode: meta.mode,
348
+ durationMs: meta.durationMs,
349
+ cpuThrottle: meta.cpuThrottle,
350
+ userAgent: meta.userAgent,
351
+ git: meta.git ?? null,
352
+ label: meta.label || '',
353
+ navStatus: meta.navStatus ?? null,
354
+ navUrl: meta.navUrl || '',
355
+ attribution: [...resolver.kinds].join(' + ') || 'none',
356
+ sampleCount: cpu?.totals.sampleCount || 0,
357
+ probeOverheadMs: round(probe?.overheadMs || 0),
358
+ },
359
+ vitals: {
360
+ // FCP/LCP belong to page load; in an interaction recording they describe an earlier load.
361
+ fcpMs: loadMode && v?.fcp ? round(v.fcp) : null,
362
+ lcpMs: loadMode && v?.lcp ? round(v.lcp.start) : null,
363
+ lcpElement: loadMode ? v?.lcp?.el || '' : '',
364
+ cls: round(
365
+ shifts.reduce((s, x) => s + x.value, 0),
366
+ 4,
367
+ ),
368
+ shifts: shifts.slice(0, 8).map(s => ({
369
+ ...s,
370
+ value: round(s.value, 4),
371
+ start: round(s.start),
372
+ sources: s.sources.filter(Boolean),
373
+ })),
374
+ tbtMs: round(tbtMs),
375
+ tbtCulprits,
376
+ longTaskCount: realLongTasks.length,
377
+ maxLongTaskMs: round(
378
+ realLongTasks.length ? ms(realLongTasks[0].durUs) : 0,
379
+ ),
380
+ inpMs: interactions.length ? interactions[0].durMs : null,
381
+ ttfbMs: probe?.navigation ? round(probe.navigation.ttfb) : null,
382
+ loadMs: probe?.navigation?.load ? round(probe.navigation.load) : null,
383
+ },
384
+ totals: {
385
+ activeMs: round(activeMs),
386
+ busyMs: round(Math.max(0, ms(trace.busyUs) - artifactMs)),
387
+ scriptingMs: round(ms(trace.totals.scripting)),
388
+ compileMs: round(ms(trace.totals.compile)),
389
+ styleMs: round(ms(trace.totals.style)),
390
+ layoutMs: round(ms(trace.totals.layout)),
391
+ paintMs: round(ms(trace.totals.paint)),
392
+ gcMs: round(cpu ? ms(cpu.totals.gcUs) : ms(trace.totals.gc)),
393
+ parseMs: round(ms(trace.totals.parse)),
394
+ otherMs: round(Math.max(0, ms(trace.totals.other) - artifactMs)),
395
+ layoutCount: trace.counts.layout,
396
+ styleRecalcCount: trace.counts.style,
397
+ forcedLayoutCount: forcedReflows.filter(f => f.kind === 'layout').length,
398
+ forcedStyleCount: forcedReflows.filter(f => f.kind === 'style').length,
399
+ forcedMs: round(forcedReflows.reduce((s, f) => s + f.ms, 0)),
400
+ maxLayoutObjects: trace.maxLayoutObjects,
401
+ },
402
+ memory: {
403
+ domNodes: m.dom?.nodes ?? m.after.Nodes ?? 0,
404
+ listeners: m.dom?.jsEventListeners ?? m.after.JSEventListeners ?? 0,
405
+ heapUsedMb: round((m.after.JSHeapUsedSize || 0) / 1048576, 1),
406
+ heapGrowthMb: round(delta('JSHeapUsedSize') / 1048576, 1),
407
+ layoutCount: delta('LayoutCount'),
408
+ recalcStyleCount: delta('RecalcStyleCount'),
409
+ },
410
+ longTasks,
411
+ thrashTasks:
412
+ realLongTasks.filter(t => t.thrash).length +
413
+ trace.tasks.filter(t => t.thrash && t.durUs < LONG_TASK_US).length,
414
+ forcedReflows: aggregateForced(forcedReflows, sourceIndex),
415
+ interactions,
416
+ react: reactReport,
417
+ framework: frameworkReport,
418
+ network,
419
+ callTree: cpu
420
+ ? buildCallTree(cpu, {
421
+ minUs: Math.max(1000, cpu.totals.activeUs * 0.004),
422
+ })
423
+ : null,
424
+ files: [],
425
+ timeline: trace.tasks
426
+ .filter(t => t.durUs >= 8000 && !artifactTasks.has(t))
427
+ .map(t => ({ startMs: round(rel(t.ts)), durMs: round(ms(t.durUs)) }))
428
+ .slice(0, 2000),
429
+ hotFunctions: hotFunctions(cpu, sourceIndex),
430
+ vendorDrivers: vendorDrivers(cpu, sourceIndex),
431
+ notes: [],
432
+ };
433
+ if (artifactMs > 0) {
434
+ report.notes.push(
435
+ `Ignored a ${round(artifactMs)} ms stall at the very start of the recording: Chrome was indexing the app's code for the CPU profiler, so it is measurement overhead, not something your app did.`,
436
+ );
437
+ }
438
+
439
+ report.files = [...files.values()]
440
+ .filter(f => f.selfMs >= 0.2 || f.renders || f.forcedCount || f.blockMs)
441
+ .map(f => ({
442
+ ...f,
443
+ selfMs: round(f.selfMs),
444
+ totalMs: round(f.totalMs),
445
+ blockMs: round(f.blockMs),
446
+ reactMs: round(f.reactMs),
447
+ forcedMs: round(f.forcedMs),
448
+ }))
449
+ .sort((a, b) => b.selfMs - a.selfMs)
450
+ .slice(0, 2500);
451
+
452
+ if (!cpu)
453
+ report.notes.push(
454
+ 'No CPU samples were captured; file attribution is limited to React and layout data.',
455
+ );
456
+ if (
457
+ report.meta.attribution === 'urls' ||
458
+ report.meta.attribution === 'none'
459
+ ) {
460
+ report.notes.push(
461
+ 'Scripts have no source maps and no webpack module markers, so time is attributed to bundles. Profile a development build for per-file results.',
462
+ );
463
+ }
464
+ report.findings = buildFindings(report);
465
+ return report;
466
+ }
467
+
468
+ function aggregateForced(list, sourceIndex) {
469
+ const map = new Map();
470
+ for (const f of list) {
471
+ const key = `${f.kind}|${f.file}|${f.fn}|${f.line}`;
472
+ const agg = map.get(key) || {
473
+ kind: f.kind,
474
+ file: f.file,
475
+ fn: f.fn,
476
+ line: f.line,
477
+ exact: f.exact,
478
+ caller: f.caller,
479
+ count: 0,
480
+ ms: 0,
481
+ };
482
+ agg.count += 1;
483
+ agg.ms += f.ms;
484
+ map.set(key, agg);
485
+ }
486
+ return [...map.values()]
487
+ .sort((a, b) => b.ms - a.ms)
488
+ .slice(0, 60)
489
+ .map(a => ({
490
+ ...a,
491
+ ms: round(a.ms),
492
+ ...locate(sourceIndex, a.file, a.fn, a.line, a.exact),
493
+ }));
494
+ }
495
+
496
+ /** The same function across several long tasks, summed and ranked. */
497
+ function mergeFunctions(longTasks) {
498
+ const merged = new Map();
499
+ for (const t of longTasks) {
500
+ for (const f of t.topFunctions) {
501
+ if (f.file.startsWith('(')) continue;
502
+ const key = `${f.file}|${f.name}`;
503
+ const cur = merged.get(key) || { ...f, ms: 0 };
504
+ cur.ms += f.ms;
505
+ merged.set(key, cur);
506
+ }
507
+ }
508
+ return [...merged.values()]
509
+ .sort((a, b) => b.ms - a.ms)
510
+ .slice(0, 4)
511
+ .map(f => ({ ...f, ms: round(f.ms) }));
512
+ }
513
+
514
+ /** The slowest functions by self time: the exact code to look at first. */
515
+ function hotFunctions(cpu, sourceIndex) {
516
+ if (!cpu) return [];
517
+ const active = cpu.totals.activeUs || 1;
518
+ return cpu.functions
519
+ .filter(fn => !fn.file.startsWith('(') && fn.selfUs >= 1000)
520
+ .sort((a, b) => b.selfUs - a.selfUs)
521
+ .slice(0, 25)
522
+ .map(fn => {
523
+ const vendor = fn.file.includes('node_modules/');
524
+ return {
525
+ name: fn.name,
526
+ file: fn.file,
527
+ vendor,
528
+ selfMs: round(ms(fn.selfUs)),
529
+ totalMs: round(ms(fn.totalUs)),
530
+ share: round((fn.selfUs / active) * 100, 1),
531
+ bundleLine: fn.bundleLine + 1,
532
+ ...(vendor
533
+ ? { line: null, lineKind: null, abs: null }
534
+ : locate(sourceIndex, fn.file, fn.name, fn.line, fn.exact)),
535
+ };
536
+ });
537
+ }
538
+
539
+ /** For each hot library, the application functions that call into it. */
540
+ function vendorDrivers(cpu, sourceIndex) {
541
+ const out = {};
542
+ if (!cpu) return out;
543
+ const totals = [...cpu.vendorDrivers].map(([pkg, drivers]) => [
544
+ pkg,
545
+ drivers,
546
+ [...drivers.values()].reduce((s, d) => s + d.us, 0),
547
+ ]);
548
+ for (const [pkg, drivers] of totals
549
+ .sort((a, b) => b[2] - a[2])
550
+ .slice(0, 15)) {
551
+ out[pkg] = [...drivers.values()]
552
+ .sort((a, b) => b.us - a.us)
553
+ .slice(0, 3)
554
+ .map(d => ({
555
+ fn: d.name,
556
+ file: d.file,
557
+ ms: round(ms(d.us)),
558
+ ...locate(sourceIndex, d.file, d.name, d.line, d.exact),
559
+ }));
560
+ }
561
+ return out;
562
+ }
563
+
564
+ function summarizeInteractions(events) {
565
+ const byInteraction = new Map();
566
+ for (const e of events) {
567
+ // Only events with an interactionId are user interactions (hover events have none);
568
+ // pointerdown/pointerup/click of one gesture share the id.
569
+ if (!e.interaction) continue;
570
+ const key = e.interaction;
571
+ const cur = byInteraction.get(key);
572
+ if (!cur || e.dur > cur.dur) byInteraction.set(key, e);
573
+ }
574
+ return [...byInteraction.values()]
575
+ .sort((a, b) => b.dur - a.dur)
576
+ .slice(0, 15)
577
+ .map(e => ({
578
+ type: e.name,
579
+ target: e.target,
580
+ startMs: round(e.start),
581
+ durMs: round(e.dur),
582
+ inputDelayMs: round(e.input),
583
+ processingMs: round(e.processing),
584
+ presentMs: round(e.present),
585
+ }));
586
+ }
587
+
588
+ function shortName(url) {
589
+ try {
590
+ const u = new URL(url);
591
+ return (
592
+ decodeURIComponent(
593
+ u.pathname.split('/').filter(Boolean).pop() || u.host,
594
+ ) + (u.search ? '?…' : '')
595
+ );
596
+ } catch {
597
+ return url.slice(0, 60);
598
+ }
599
+ }
600
+
601
+ function summarizeNetwork(resources, sinceMs) {
602
+ const list = resources
603
+ .filter(r => r.start >= sinceMs)
604
+ .map(r => ({
605
+ url: r.url,
606
+ name: shortName(r.url),
607
+ type: r.type,
608
+ startMs: round(r.start),
609
+ durMs: round(r.dur),
610
+ transfer: r.transfer,
611
+ encoded: r.encoded,
612
+ decoded: r.decoded,
613
+ blocking: r.blocking,
614
+ }));
615
+ const compressible = /\.(?:js|mjs|css|json|svg|html)(?:$|\?)/i;
616
+ const dupes = new Map();
617
+ for (const r of list) dupes.set(r.url, (dupes.get(r.url) || 0) + 1);
618
+ return {
619
+ count: list.length,
620
+ totalEncoded: list.reduce((s, r) => s + (r.encoded || 0), 0),
621
+ totalDecoded: list.reduce((s, r) => s + (r.decoded || 0), 0),
622
+ slow: [...list].sort((a, b) => b.durMs - a.durMs).slice(0, 15),
623
+ large: [...list].sort((a, b) => b.encoded - a.encoded).slice(0, 15),
624
+ uncompressed: list
625
+ .filter(
626
+ r =>
627
+ compressible.test(r.url) &&
628
+ r.decoded > 50 * 1024 &&
629
+ r.encoded >= r.decoded * 0.95 &&
630
+ r.transfer > 0,
631
+ )
632
+ .sort((a, b) => b.decoded - a.decoded)
633
+ .slice(0, 10),
634
+ duplicates: [...dupes]
635
+ .filter(([, n]) => n >= 3)
636
+ .map(([url, n]) => ({ url, name: shortName(url), count: n }))
637
+ .slice(0, 10),
638
+ };
639
+ }
640
+
641
+ function summarizeReact(
642
+ probe,
643
+ locations,
644
+ resolver,
645
+ files,
646
+ durationMs,
647
+ sourceIndex,
648
+ ) {
649
+ const empty = {
650
+ detected: false,
651
+ framework: probe?.info?.framework || '',
652
+ hasTimings: false,
653
+ version: '',
654
+ components: [],
655
+ commits: { count: 0, totalMs: 0, maxMs: 0 },
656
+ slowCommits: [],
657
+ commitSeries: [],
658
+ };
659
+ if (!probe || !probe.info?.detected) return empty;
660
+ const originMs = probe.since || 0;
661
+ const components = probe.components
662
+ .map(c => {
663
+ let loc;
664
+ if (c.file) {
665
+ // Adapters that know the component's source file directly (e.g. Vue SFCs).
666
+ const rel = toProjectRelative(c.file);
667
+ loc = {
668
+ file: rel,
669
+ line: c.line || null,
670
+ vendor: /(^|\/)node_modules\//.test(rel),
671
+ pkg: null,
672
+ exact: false,
673
+ };
674
+ } else if (locations.has(c.id)) {
675
+ const l = locations.get(c.id);
676
+ const script = resolver.scripts.get(String(l.scriptId));
677
+ loc = resolver.resolve(
678
+ String(l.scriptId),
679
+ script?.url || '',
680
+ l.lineNumber,
681
+ l.columnNumber,
682
+ );
683
+ } else loc = { file: '', line: null, vendor: false, pkg: null };
684
+
685
+ const unstableProps = Object.entries(c.props)
686
+ .map(([prop, r]) => {
687
+ const kind = r.fn >= r.obj ? 'function' : 'object';
688
+ // Equality was only checked for the first renders; scale the ratio up to all changes.
689
+ const count = r.sampled
690
+ ? Math.round((r.n * Math.max(r.fn, r.obj)) / r.sampled)
691
+ : 0;
692
+ return { prop, count, kind, changed: r.n };
693
+ })
694
+ .filter(p => p.count > 0)
695
+ .sort((a, b) => b.count - a.count);
696
+ const changedProps = Object.entries(c.props)
697
+ .map(([prop, r]) => ({ prop, count: r.n }))
698
+ .sort((a, b) => b.count - a.count)
699
+ .slice(0, 6);
700
+ const topParent =
701
+ Object.entries(c.parents).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
702
+ const wastedMs = c.renders ? (c.selfMs * c.wasted) / c.renders : 0;
703
+ return {
704
+ id: c.id,
705
+ name: c.name,
706
+ kind: c.kind,
707
+ file: loc.file,
708
+ ...(loc.vendor
709
+ ? { line: loc.line, lineKind: null, abs: null }
710
+ : locate(sourceIndex, loc.file, c.name, loc.line, loc.exact)),
711
+ vendor: loc.vendor,
712
+ renders: c.renders,
713
+ mounts: c.mounts,
714
+ wasted: c.wasted,
715
+ selfMs: round(c.selfMs),
716
+ totalMs: round(c.totalMs),
717
+ maxMs: round(c.maxMs),
718
+ wastedMs: round(wastedMs),
719
+ reasons: c.reasons,
720
+ unstableProps,
721
+ changedProps,
722
+ topParent,
723
+ };
724
+ })
725
+ .sort((a, b) => b.selfMs - a.selfMs || b.renders - a.renders);
726
+
727
+ for (const c of components) {
728
+ if (!c.file) continue;
729
+ const entry = fileEntry(files, {
730
+ file: c.file,
731
+ vendor: c.vendor,
732
+ pkg: packageOf(c.file),
733
+ });
734
+ entry.reactMs += c.selfMs;
735
+ entry.renders += c.renders;
736
+ entry.wasted += c.wasted;
737
+ entry.components.push({
738
+ name: c.name,
739
+ renders: c.renders,
740
+ wasted: c.wasted,
741
+ selfMs: c.selfMs,
742
+ line: c.line,
743
+ });
744
+ }
745
+ const commits = probe.commits;
746
+ const sorted = [...commits].sort((a, b) => b.dur - a.dur);
747
+ return {
748
+ detected: true,
749
+ framework: probe.info.framework || 'react',
750
+ hasTimings: probe.info.timings,
751
+ version: probe.info.version,
752
+ components: components.slice(0, 600),
753
+ commits: {
754
+ count: probe.commitCount,
755
+ totalMs: round(commits.reduce((s, c) => s + c.dur, 0)),
756
+ maxMs: round(sorted[0]?.dur || 0),
757
+ },
758
+ slowCommits: sorted.slice(0, 10).map(c => ({
759
+ tMs: round(c.t - originMs),
760
+ dur: round(c.dur),
761
+ rendered: c.rendered,
762
+ wasted: c.wasted,
763
+ })),
764
+ commitSeries: commits
765
+ .filter(c => c.dur >= 4)
766
+ .slice(0, 1500)
767
+ .map(c => ({
768
+ tMs: round(c.t - originMs),
769
+ dur: round(c.dur),
770
+ rendered: c.rendered,
771
+ wasted: c.wasted,
772
+ })),
773
+ durationMs,
774
+ };
775
+ }