browsertime 27.0.4 → 27.2.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 (31) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/chrome/parseCpuTrace.js +38 -2
  3. package/lib/chrome/trace/forced-reflows.js +78 -0
  4. package/lib/chrome/trace/index.js +41 -0
  5. package/lib/chrome/trace/main-thread-tasks.js +196 -0
  6. package/lib/chrome/trace/non-composited-animations.js +55 -0
  7. package/lib/chrome/trace/script-costs.js +88 -0
  8. package/lib/chrome/trace/task-groups.js +196 -0
  9. package/lib/chrome/trace/trace-of-tab.js +168 -0
  10. package/lib/chrome/trace/tracing-processor.js +95 -0
  11. package/lib/chrome/webdriver/chromium.js +13 -0
  12. package/lib/support/har/index.js +22 -0
  13. package/package.json +3 -4
  14. package/types/chrome/parseCpuTrace.d.ts +6 -0
  15. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  16. package/types/chrome/trace/forced-reflows.d.ts +8 -0
  17. package/types/chrome/trace/forced-reflows.d.ts.map +1 -0
  18. package/types/chrome/trace/index.d.ts +19 -0
  19. package/types/chrome/trace/index.d.ts.map +1 -0
  20. package/types/chrome/trace/main-thread-tasks.d.ts +31 -0
  21. package/types/chrome/trace/main-thread-tasks.d.ts.map +1 -0
  22. package/types/chrome/trace/non-composited-animations.d.ts +33 -0
  23. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -0
  24. package/types/chrome/trace/script-costs.d.ts +14 -0
  25. package/types/chrome/trace/script-costs.d.ts.map +1 -0
  26. package/types/chrome/trace/task-groups.d.ts +65 -0
  27. package/types/chrome/trace/task-groups.d.ts.map +1 -0
  28. package/types/chrome/trace/trace-of-tab.d.ts +32 -0
  29. package/types/chrome/trace/trace-of-tab.d.ts.map +1 -0
  30. package/types/chrome/trace/tracing-processor.d.ts +24 -0
  31. package/types/chrome/trace/tracing-processor.d.ts.map +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 27.2.0 - 2026-05-12
4
+
5
+ ### Added
6
+ * Surface the page-level `recalculateStyle` summary (beforeFCP / beforeLCP element counts + durations) on the first HAR page as `_renderBlocking.recalculateStyle`. The per-request `_renderBlocking` map already projected onto each entry stays where it is — the page-level summary is what powers the "Elements that needed recalculate style before FCP" view and was previously only reachable via `browsertime.json` [#2466](https://github.com/sitespeedio/browsertime/pull/2466).
7
+
8
+ ## 27.1.0 - 2026-05-06
9
+
10
+ ### Added
11
+ * Three new analyses on `result.cpu` derived from the same Chrome `trace.json` that's already collected when `--cpu` is on. `scriptCosts` produces the per-URL parse / compile / execute / total breakdown that Lighthouse's bootup-time audit shows, sorted by total descending. `forcedReflows` walks the main-thread task tree for `Layout` / `UpdateLayoutTree` events nested inside JS-driven tasks (`EventDispatch`, `FunctionCall`, `TimerFire`, `FireAnimationFrame`, …) — every match is a synchronous reflow caused by JavaScript reading a layout-triggering property mid-handler, reported with the script that triggered it. `nonCompositedAnimations` surfaces `Animation` events whose `compositeFailed` bitmask is non-zero, returning the unsupported properties (`top`, `box-shadow`, `filter`, …) so consumers can see what to swap for the GPU-friendly equivalent. Each new analysis is wrapped in its own try / catch so a bug in one can't poison the existing `categories` / `events` / `urls` payload [#PR1](https://github.com/sitespeedio/browsertime/pull/PR1).
12
+
13
+ ### Fixed
14
+ * Replace the unmaintained `@sitespeed.io/tracium` dependency (extracted from Lighthouse circa 2017, last release 0.3.3) with an in-tree ESM port at `lib/chrome/trace/`. The algorithm is a 1:1 port; output of `computeMainThreadTasks` is byte-equivalent for any trace, so existing `result.cpu.{categories,events,urls}` consumers see no change. Pulling the parser inline lets new analyses ship at Browsertime's release cadence without a second `npm publish` step in between [#PR1](https://github.com/sitespeedio/browsertime/pull/PR1).
15
+ * Modernise the trace-event classifier. The original list only knew about ~30 event names, so on busy 2026-era pages roughly half the trace fell through to "other" — on a sample cnet run that meant 1.3 s of "other" hiding `RunTask`, `v8.run`, `IntersectionObserverController::computeIntersections`, `PrePaint`, `Commit`, `Layerize`, `v8.callFunction`, `V8.DeserializeContext` and friends. The expanded list draws from modern Lighthouse + the WebPageTest-derived map in waterfall-tools + direct sampling of real traces, and a new `groupForEvent()` lookup adds `V8.GC*` prefix matching so future V8 GC phases auto-classify [#PR1](https://github.com/sitespeedio/browsertime/pull/PR1).
16
+ * Bump `chrome-har` to 1.3.0 to pick up the new `_renderBlocking` field on each entry, lifting Chrome's CDP `renderBlockingStatus` (Chrome 108+) so downstream HAR consumers can see which resources blocked first paint without inferring it from the trace.
17
+
3
18
  ## 27.0.4 - 2026-05-05
4
19
  ### Fixed
5
20
  * Fix Safari iOS video time scale [#2457](https://github.com/sitespeedio/browsertime/pull/2457). This make the video and Safari metric match.
@@ -1,5 +1,10 @@
1
1
  import { getLogger } from '@sitespeed.io/log';
2
- import { computeMainThreadTasks } from '@sitespeed.io/tracium';
2
+ import {
3
+ computeMainThreadTasks,
4
+ computeScriptCosts,
5
+ computeForcedReflows,
6
+ computeNonCompositedAnimations
7
+ } from './trace/index.js';
3
8
  const log = getLogger('browsertime.chrome.cpu');
4
9
 
5
10
  function round(number_, decimals = 3) {
@@ -77,8 +82,39 @@ export async function parseCPUTrace(tracelog, url) {
77
82
 
78
83
  cleanedUrls.sort(slowestFirst);
79
84
 
85
+ // New analyses derived from the same trace. Each is independent
86
+ // of the others and won't produce noise on pages where the
87
+ // signal isn't present (empty arrays). Wrapped in a try so a
88
+ // bug in one analysis can't poison the existing categories /
89
+ // events / urls payload.
90
+ let scriptCosts = [];
91
+ let forcedReflows = [];
92
+ let nonCompositedAnimations = [];
93
+ try {
94
+ scriptCosts = computeScriptCosts(tracelog);
95
+ } catch (error) {
96
+ log.debug('computeScriptCosts failed: %s', error.message);
97
+ }
98
+ try {
99
+ forcedReflows = computeForcedReflows(tracelog);
100
+ } catch (error) {
101
+ log.debug('computeForcedReflows failed: %s', error.message);
102
+ }
103
+ try {
104
+ nonCompositedAnimations = computeNonCompositedAnimations(tracelog);
105
+ } catch (error) {
106
+ log.debug('computeNonCompositedAnimations failed: %s', error.message);
107
+ }
108
+
80
109
  log.debug('Chrome trace log finished parsed and sorted.');
81
- return { categories, events, urls: cleanedUrls };
110
+ return {
111
+ categories,
112
+ events,
113
+ urls: cleanedUrls,
114
+ scriptCosts,
115
+ forcedReflows,
116
+ nonCompositedAnimations
117
+ };
82
118
  } catch (error) {
83
119
  log.error(
84
120
  'Could not parse the trace log from Chrome for url %s',
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Forced reflow / synchronous layout detection.
3
+ *
4
+ * A forced reflow happens when JavaScript reads a layout-triggering
5
+ * property (offsetTop, getBoundingClientRect, …) inside an event /
6
+ * timer / animation-frame handler. The browser must synchronously
7
+ * recompute layout to answer, blocking the main thread until it's
8
+ * done. Classic perf-bug pattern; expensive on long DOM trees.
9
+ *
10
+ * In the trace this shows up as a Layout / UpdateLayoutTree event
11
+ * nested inside a JS-driven task (EventDispatch, FunctionCall,
12
+ * TimerFire, FireAnimationFrame, …). The unforced version of the
13
+ * same event happens at the top level between tasks. So the rule is:
14
+ * if the Layout's chain of ancestors includes any JS-driven task,
15
+ * call it forced.
16
+ *
17
+ * Returns: [{ eventName, duration, startTime, triggeredBy,
18
+ * triggeredByUrl }, …]
19
+ * eventName — Layout or UpdateLayoutTree
20
+ * duration / startTime — task timing in ms (already navstart-
21
+ * relative from main-thread-tasks.js)
22
+ * triggeredBy — the closest JS-driven ancestor's event name
23
+ * (e.g. EventDispatch, FunctionCall)
24
+ * triggeredByUrl — most-specific attributable URL of that
25
+ * ancestor; the script the user can open and fix
26
+ */
27
+
28
+ import { compute } from './main-thread-tasks.js';
29
+
30
+ const JS_DRIVEN = new Set([
31
+ 'EventDispatch',
32
+ 'EvaluateScript',
33
+ 'v8.evaluateModule',
34
+ 'FunctionCall',
35
+ 'TimerFire',
36
+ 'FireIdleCallback',
37
+ 'FireAnimationFrame',
38
+ 'RunMicrotasks',
39
+ 'V8.Execute'
40
+ ]);
41
+
42
+ const LAYOUT_EVENTS = new Set(['Layout', 'UpdateLayoutTree']);
43
+
44
+ function urlFor(task) {
45
+ if (!task || !task.attributableURLs) return '';
46
+ return task.attributableURLs.at(-1) || '';
47
+ }
48
+
49
+ export function computeForcedReflows(trace) {
50
+ const allTasks = compute(trace);
51
+ const reflows = [];
52
+
53
+ // Walk every task — `compute()` returns the flat list with
54
+ // `.parent` linkage. For each Layout / UpdateLayoutTree, walk up
55
+ // the parent chain to find the nearest JS-driven ancestor. If we
56
+ // find one, the layout was forced.
57
+ for (const task of allTasks) {
58
+ if (!LAYOUT_EVENTS.has(task.event.name)) continue;
59
+
60
+ let ancestor = task.parent;
61
+ while (ancestor) {
62
+ if (JS_DRIVEN.has(ancestor.event.name)) break;
63
+ ancestor = ancestor.parent;
64
+ }
65
+ if (!ancestor) continue;
66
+
67
+ reflows.push({
68
+ eventName: task.event.name,
69
+ duration: Math.round(task.duration * 10) / 10,
70
+ startTime: Math.round(task.startTime * 10) / 10,
71
+ triggeredBy: ancestor.event.name,
72
+ triggeredByUrl: urlFor(ancestor)
73
+ });
74
+ }
75
+
76
+ reflows.sort((a, b) => b.duration - a.duration);
77
+ return reflows;
78
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Chrome trace.json analyses, used by parseCpuTrace.js to produce
3
+ * the cpu/categories/events/urls payload that flows into the result
4
+ * JSON. Replaces the legacy `@sitespeed.io/tracium` dependency
5
+ * (extracted from Lighthouse 2017, unmaintained) with an in-tree
6
+ * ESM port of the same algorithm. Kept byte-equivalent for
7
+ * `computeMainThreadTasks` so existing consumers see the same task
8
+ * tree; new analyses (script costs, layout shift attribution,
9
+ * forced layouts, frame stability) will land here as additional
10
+ * exports rather than a separate package.
11
+ */
12
+
13
+ import { compute } from './main-thread-tasks.js';
14
+
15
+ export { computeScriptCosts } from './script-costs.js';
16
+ export { computeForcedReflows } from './forced-reflows.js';
17
+ export { computeNonCompositedAnimations } from './non-composited-animations.js';
18
+
19
+ /**
20
+ * Compute the hierarchical main-thread task tree for a trace.
21
+ * Each task has its kind classified into one of:
22
+ * parseHTML | styleLayout | paintCompositeRender |
23
+ * scriptParseCompile | scriptEvaluation | garbageCollection | other
24
+ *
25
+ * @param {Object} trace Parsed Chrome trace.json (with .traceEvents).
26
+ * @param {Object} [options]
27
+ * @param {boolean} [options.flatten=false] When true, return all
28
+ * tasks flat (parents and children); when false, only top-level.
29
+ * @returns {Array<Object>}
30
+ */
31
+ export function computeMainThreadTasks(trace, options = {}) {
32
+ const { flatten = false } = options;
33
+ const allTasks = compute(trace);
34
+ const result = [];
35
+ for (const task of allTasks) {
36
+ task.kind = task.group.id;
37
+ delete task.group;
38
+ if (!task.parent || flatten) result.push(task);
39
+ }
40
+ return result;
41
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Trace events → hierarchical task list. Ported verbatim from
3
+ * @sitespeed.io/tracium 0.3.3 (Lighthouse 2017 lineage). Each
4
+ * resulting `TaskNode` carries:
5
+ * { event, startTime, endTime, duration, selfTime,
6
+ * attributableURLs, parent, children, group }
7
+ *
8
+ * Times are returned in ms, rebased to the first task's startTime.
9
+ *
10
+ * Reference (Lighthouse upstream this file came from):
11
+ * https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/lib/tracehouse/main-thread-tasks.js
12
+ *
13
+ * Trace event format:
14
+ * https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview
15
+ */
16
+
17
+ import { taskGroups, groupForEvent } from './task-groups.js';
18
+ import { computeTraceOfTab } from './trace-of-tab.js';
19
+
20
+ function createNewTaskNode(event, parent) {
21
+ const newTask = {
22
+ event,
23
+ startTime: event.ts,
24
+ endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : Number.NaN,
25
+ parent,
26
+ children: [],
27
+ // Filled in later
28
+ attributableURLs: [],
29
+ group: taskGroups.other,
30
+ duration: Number.NaN,
31
+ selfTime: Number.NaN
32
+ };
33
+ if (parent) {
34
+ parent.children.push(newTask);
35
+ }
36
+ return newTask;
37
+ }
38
+
39
+ function createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
40
+ const tasks = [];
41
+ let currentTask;
42
+
43
+ for (const event of mainThreadEvents) {
44
+ // TimerInstall is an instant event (ph === 'I') — process it
45
+ // first so timer→task attribution works for subsequent TimerFire.
46
+ if (event.name === 'TimerInstall' && currentTask) {
47
+ const timerId = event.args.data.timerId;
48
+ priorTaskData.timers.set(timerId, currentTask);
49
+ }
50
+
51
+ // Only X (Complete), B (Begin), E (End) carry the data we need.
52
+ if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue;
53
+
54
+ // Walk up the stack until we're inside a task that hasn't ended.
55
+ while (
56
+ currentTask &&
57
+ Number.isFinite(currentTask.endTime) &&
58
+ currentTask.endTime <= event.ts
59
+ ) {
60
+ currentTask = currentTask.parent;
61
+ }
62
+
63
+ if (!currentTask) {
64
+ // Can't start a task with an end event — real-world traces
65
+ // sometimes have a stray E without a matching B. Skip rather
66
+ // than throw so a partly-malformed trace still produces output.
67
+ if (event.ph === 'E') continue;
68
+ currentTask = createNewTaskNode(event);
69
+ tasks.push(currentTask);
70
+ continue;
71
+ }
72
+
73
+ if (event.ph === 'X' || event.ph === 'B') {
74
+ // Nested event — child of currentTask.
75
+ const newTask = createNewTaskNode(event, currentTask);
76
+ tasks.push(newTask);
77
+ currentTask = newTask;
78
+ } else {
79
+ // event.ph === 'E' — close the current task. If currentTask
80
+ // wasn't a B (e.g. the previous event was an X), the trace is
81
+ // malformed; tolerate by skipping rather than throwing.
82
+ if (currentTask.event.ph !== 'B') continue;
83
+ currentTask.endTime = event.ts;
84
+ currentTask = currentTask.parent;
85
+ }
86
+ }
87
+
88
+ // Any tasks still open at the end of the trace end at traceEndTs.
89
+ while (currentTask && !Number.isFinite(currentTask.endTime)) {
90
+ currentTask.endTime = traceEndTs;
91
+ currentTask = currentTask.parent;
92
+ }
93
+
94
+ return tasks;
95
+ }
96
+
97
+ function computeRecursiveSelfTime(task, parent) {
98
+ if (parent && task.endTime > parent.endTime) {
99
+ // Real-world traces occasionally produce children that report an
100
+ // endTime past the parent's. Tolerate by treating the duration
101
+ // as zero rather than throwing — the rest of the analysis stays
102
+ // useful.
103
+ return 0;
104
+ }
105
+ const childTime = task.children
106
+ .map(child => computeRecursiveSelfTime(child, task))
107
+ .reduce((sum, child) => sum + child, 0);
108
+ task.duration = task.endTime - task.startTime;
109
+ task.selfTime = task.duration - childTime;
110
+ return task.duration;
111
+ }
112
+
113
+ function computeRecursiveAttributableURLs(task, parentURLs, priorTaskData) {
114
+ const argsData = task.event.args.data || {};
115
+ const stackFrameURLs = (argsData.stackTrace || []).map(entry => entry.url);
116
+
117
+ let taskURLs = [];
118
+ switch (task.event.name) {
119
+ case 'v8.compile':
120
+ case 'EvaluateScript':
121
+ case 'FunctionCall': {
122
+ taskURLs = [argsData.url, ...stackFrameURLs];
123
+ break;
124
+ }
125
+ case 'v8.compileModule': {
126
+ taskURLs = [task.event.args.fileName, ...stackFrameURLs];
127
+ break;
128
+ }
129
+ case 'TimerFire': {
130
+ const timerId = task.event.args.data.timerId;
131
+ const timerInstallerTaskNode = priorTaskData.timers.get(timerId);
132
+ if (!timerInstallerTaskNode) break;
133
+ taskURLs = [
134
+ ...timerInstallerTaskNode.attributableURLs,
135
+ ...stackFrameURLs
136
+ ];
137
+ break;
138
+ }
139
+ default: {
140
+ taskURLs = stackFrameURLs;
141
+ break;
142
+ }
143
+ }
144
+
145
+ const attributableURLs = [...parentURLs];
146
+ for (const url of taskURLs) {
147
+ if (!url) continue; // empty URL
148
+ if (attributableURLs.at(-1) === url) continue; // dedupe consecutive
149
+ attributableURLs.push(url);
150
+ }
151
+ task.attributableURLs = attributableURLs;
152
+ for (const child of task.children)
153
+ computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData);
154
+ }
155
+
156
+ function computeRecursiveTaskGroup(task, parentGroup) {
157
+ const group = groupForEvent(task.event.name);
158
+ task.group = group || parentGroup || taskGroups.other;
159
+ for (const child of task.children)
160
+ computeRecursiveTaskGroup(child, task.group);
161
+ }
162
+
163
+ export function getMainThreadTasks(traceEvents, traceEndTs) {
164
+ const timers = new Map();
165
+ const priorTaskData = { timers };
166
+ const tasks = createTasksFromEvents(traceEvents, priorTaskData, traceEndTs);
167
+
168
+ for (const task of tasks) {
169
+ if (task.parent) continue;
170
+ computeRecursiveSelfTime(task);
171
+ computeRecursiveAttributableURLs(task, [], priorTaskData);
172
+ computeRecursiveTaskGroup(task);
173
+ }
174
+
175
+ // Rebase all the times to be relative to start of trace in ms.
176
+ const firstTs = (tasks[0] || { startTime: 0 }).startTime;
177
+ for (const task of tasks) {
178
+ task.startTime = (task.startTime - firstTs) / 1000;
179
+ task.endTime = (task.endTime - firstTs) / 1000;
180
+ task.duration /= 1000;
181
+ task.selfTime /= 1000;
182
+ if (!Number.isFinite(task.selfTime)) {
183
+ // Real-world tolerance — see computeRecursiveSelfTime. If a
184
+ // task slipped through with NaN selfTime, treat it as zero so
185
+ // downstream summing doesn't poison every category.
186
+ task.selfTime = 0;
187
+ }
188
+ }
189
+
190
+ return tasks;
191
+ }
192
+
193
+ export function compute(trace) {
194
+ const { mainThreadEvents, timestamps } = computeTraceOfTab(trace);
195
+ return getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
196
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Non-composited animations — animations that triggered Layout or
3
+ * Paint instead of running entirely on the compositor thread.
4
+ *
5
+ * Compositor-only animations (transform, opacity) hand off to the
6
+ * GPU and don't block the main thread. Anything that touches
7
+ * `top` / `left` / `width` / `box-shadow` / non-transformable
8
+ * filters / etc. forces a per-frame layout or paint, which means
9
+ * jank on busy main threads.
10
+ *
11
+ * Chrome marks these in the trace via `Animation` events whose
12
+ * `args.data.compositeFailed` bitmask is non-zero, plus a list of
13
+ * `unsupportedProperties` strings. We surface the raw values; the
14
+ * consumer can map the bitmask to human reasons (see Lighthouse's
15
+ * non-composited-animations audit for the canonical mapping).
16
+ *
17
+ * Returns: [{ name, id, compositeFailed, unsupportedProperties,
18
+ * startTime }, …]
19
+ * name — args.data.nodeName when present (rare)
20
+ * id — args.data.id (compositor-internal)
21
+ * compositeFailed — bitmask of failure reasons
22
+ * unsupportedProperties — array of property names that blocked
23
+ * composite (e.g. ['top','box-shadow'])
24
+ * startTime — event ts in microseconds (raw trace timestamp)
25
+ */
26
+
27
+ export function computeNonCompositedAnimations(trace) {
28
+ const seen = new Set();
29
+ const animations = [];
30
+
31
+ for (const event of trace.traceEvents) {
32
+ if (event.name !== 'Animation') continue;
33
+ // Animation events fire at multiple phases; the compositeFailed
34
+ // bitmask is set once per animation lifecycle. Dedupe by id so we
35
+ // don't list the same failed animation N times.
36
+ const data = (event.args && event.args.data) || {};
37
+ const failed = data.compositeFailed || 0;
38
+ const unsupported = data.unsupportedProperties || [];
39
+ if (!failed && unsupported.length === 0) continue;
40
+
41
+ const id = data.id || `${event.ts}`;
42
+ if (seen.has(id)) continue;
43
+ seen.add(id);
44
+
45
+ animations.push({
46
+ name: data.nodeName || '',
47
+ id: data.id || '',
48
+ compositeFailed: failed,
49
+ unsupportedProperties: unsupported,
50
+ startTime: event.ts
51
+ });
52
+ }
53
+
54
+ return animations;
55
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Per-script JS cost breakdown — the same data Lighthouse's
3
+ * `bootup-time` audit computes, surfaced as a structured array so
4
+ * downstream consumers can render a "top scripts by main-thread
5
+ * time" table without re-deriving it from raw tasks.
6
+ *
7
+ * For each attributable URL we sum the self-time of every main-thread
8
+ * task that's classified as parse / compile / execute, and return:
9
+ * [{ url, parse, compile, execute, total }, …]
10
+ *
11
+ * Reference: Lighthouse `lighthouse-core/audits/bootup-time.js`.
12
+ *
13
+ * Times are in ms, rounded to one decimal. Sorted by `total` desc so
14
+ * the worst offenders are at index 0.
15
+ */
16
+
17
+ import { compute } from './main-thread-tasks.js';
18
+
19
+ // Trace event name → cost bucket. Built once at module-load time so
20
+ // the inner hot loop is a single Map.get per task.
21
+ const PARSE_EVENTS = new Set(['v8.parseOnBackground']);
22
+ const COMPILE_EVENTS = new Set(['v8.compile', 'v8.compileModule']);
23
+ const EXECUTE_EVENTS = new Set([
24
+ 'EvaluateScript',
25
+ 'v8.evaluateModule',
26
+ 'FunctionCall',
27
+ 'TimerFire',
28
+ 'FireIdleCallback',
29
+ 'FireAnimationFrame',
30
+ 'RunMicrotasks',
31
+ 'V8.Execute',
32
+ 'EventDispatch'
33
+ ]);
34
+
35
+ function bucketFor(eventName) {
36
+ if (PARSE_EVENTS.has(eventName)) return 'parse';
37
+ if (COMPILE_EVENTS.has(eventName)) return 'compile';
38
+ if (EXECUTE_EVENTS.has(eventName)) return 'execute';
39
+ return;
40
+ }
41
+
42
+ function round(ms) {
43
+ return Math.round(ms * 10) / 10;
44
+ }
45
+
46
+ /**
47
+ * @param {Object} trace Parsed Chrome trace.json (with .traceEvents).
48
+ * @returns {Array<{url:string, parse:number, compile:number,
49
+ * execute:number, total:number}>}
50
+ * Per-URL JS cost in ms, sorted by total desc.
51
+ */
52
+ export function computeScriptCosts(trace) {
53
+ const allTasks = compute(trace);
54
+ const byUrl = new Map();
55
+
56
+ for (const task of allTasks) {
57
+ const bucket = bucketFor(task.event.name);
58
+ if (!bucket) continue;
59
+
60
+ // Lighthouse credits the most-specific URL in the attributable
61
+ // chain (the innermost stack frame / target script). For an event
62
+ // like EvaluateScript this is the URL of the script being
63
+ // evaluated; for FunctionCall it's the script the function lives
64
+ // in. attributableURLs is built bottom-up so the last element is
65
+ // the right one.
66
+ const url = task.attributableURLs.at(-1);
67
+ if (!url) continue;
68
+
69
+ let entry = byUrl.get(url);
70
+ if (!entry) {
71
+ entry = { url, parse: 0, compile: 0, execute: 0, total: 0 };
72
+ byUrl.set(url, entry);
73
+ }
74
+ entry[bucket] += task.selfTime;
75
+ entry.total += task.selfTime;
76
+ }
77
+
78
+ const result = [];
79
+ for (const entry of byUrl.values()) {
80
+ entry.parse = round(entry.parse);
81
+ entry.compile = round(entry.compile);
82
+ entry.execute = round(entry.execute);
83
+ entry.total = round(entry.total);
84
+ result.push(entry);
85
+ }
86
+ result.sort((a, b) => b.total - a.total);
87
+ return result;
88
+ }