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.
- package/CHANGELOG.md +15 -0
- package/lib/chrome/parseCpuTrace.js +38 -2
- package/lib/chrome/trace/forced-reflows.js +78 -0
- package/lib/chrome/trace/index.js +41 -0
- package/lib/chrome/trace/main-thread-tasks.js +196 -0
- package/lib/chrome/trace/non-composited-animations.js +55 -0
- package/lib/chrome/trace/script-costs.js +88 -0
- package/lib/chrome/trace/task-groups.js +196 -0
- package/lib/chrome/trace/trace-of-tab.js +168 -0
- package/lib/chrome/trace/tracing-processor.js +95 -0
- package/lib/chrome/webdriver/chromium.js +13 -0
- package/lib/support/har/index.js +22 -0
- package/package.json +3 -4
- package/types/chrome/parseCpuTrace.d.ts +6 -0
- package/types/chrome/parseCpuTrace.d.ts.map +1 -1
- package/types/chrome/trace/forced-reflows.d.ts +8 -0
- package/types/chrome/trace/forced-reflows.d.ts.map +1 -0
- package/types/chrome/trace/index.d.ts +19 -0
- package/types/chrome/trace/index.d.ts.map +1 -0
- package/types/chrome/trace/main-thread-tasks.d.ts +31 -0
- package/types/chrome/trace/main-thread-tasks.d.ts.map +1 -0
- package/types/chrome/trace/non-composited-animations.d.ts +33 -0
- package/types/chrome/trace/non-composited-animations.d.ts.map +1 -0
- package/types/chrome/trace/script-costs.d.ts +14 -0
- package/types/chrome/trace/script-costs.d.ts.map +1 -0
- package/types/chrome/trace/task-groups.d.ts +65 -0
- package/types/chrome/trace/task-groups.d.ts.map +1 -0
- package/types/chrome/trace/trace-of-tab.d.ts +32 -0
- package/types/chrome/trace/trace-of-tab.d.ts.map +1 -0
- package/types/chrome/trace/tracing-processor.d.ts +24 -0
- 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 {
|
|
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 {
|
|
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
|
+
}
|