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