browsertime 27.4.1 → 28.0.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 +33 -0
- package/bin/browsertime.js +28 -4
- package/lib/chrome/coverage.js +89 -19
- package/lib/chrome/mediawikiResourceLoader.js +223 -0
- package/lib/chrome/parseCpuTrace.js +87 -2
- package/lib/chrome/trace/blocking-time.js +189 -0
- package/lib/chrome/trace/domain-breakdown.js +86 -0
- package/lib/chrome/trace/frame-stability.js +135 -0
- package/lib/chrome/trace/function-costs.js +281 -0
- package/lib/chrome/trace/index.js +7 -0
- package/lib/chrome/trace/module-costs.js +137 -0
- package/lib/chrome/trace/non-composited-animations.js +50 -5
- package/lib/chrome/trace/selector-stats.js +110 -0
- package/lib/chrome/trace/style-invalidations.js +162 -0
- package/lib/chrome/trace/task-groups.js +28 -2
- package/lib/chrome/trace/timer-costs.js +183 -0
- package/lib/chrome/webdriver/chromium.js +114 -0
- package/lib/chrome/webdriver/traceUtilities.js +11 -1
- package/lib/core/engine/collector.js +12 -0
- package/lib/core/engine/index.js +6 -0
- package/lib/core/engine/iteration.js +5 -0
- package/lib/support/cli.js +1 -1
- package/lib/support/har/index.js +44 -1
- package/package.json +6 -6
- package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
- package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
- package/types/chrome/parseCpuTrace.d.ts +1 -26
- package/types/chrome/parseCpuTrace.d.ts.map +1 -1
- package/types/chrome/trace/blocking-time.d.ts +7 -0
- package/types/chrome/trace/blocking-time.d.ts.map +1 -0
- package/types/chrome/trace/domain-breakdown.d.ts +11 -0
- package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
- package/types/chrome/trace/frame-stability.d.ts +11 -0
- package/types/chrome/trace/frame-stability.d.ts.map +1 -0
- package/types/chrome/trace/function-costs.d.ts +6 -0
- package/types/chrome/trace/function-costs.d.ts.map +1 -0
- package/types/chrome/trace/index.d.ts +7 -0
- package/types/chrome/trace/index.d.ts.map +1 -1
- package/types/chrome/trace/module-costs.d.ts +20 -0
- package/types/chrome/trace/module-costs.d.ts.map +1 -0
- package/types/chrome/trace/non-composited-animations.d.ts +1 -25
- package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
- package/types/chrome/trace/selector-stats.d.ts +8 -0
- package/types/chrome/trace/selector-stats.d.ts.map +1 -0
- package/types/chrome/trace/style-invalidations.d.ts +9 -0
- package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
- package/types/chrome/trace/task-groups.d.ts.map +1 -1
- package/types/chrome/trace/timer-costs.d.ts +10 -0
- package/types/chrome/trace/timer-costs.d.ts.map +1 -0
- package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
|
@@ -15,6 +15,13 @@ import { compute } from './main-thread-tasks.js';
|
|
|
15
15
|
export { computeScriptCosts } from './script-costs.js';
|
|
16
16
|
export { computeForcedReflows } from './forced-reflows.js';
|
|
17
17
|
export { computeNonCompositedAnimations } from './non-composited-animations.js';
|
|
18
|
+
export { computeFrameStability } from './frame-stability.js';
|
|
19
|
+
export { computeFunctionCosts } from './function-costs.js';
|
|
20
|
+
export { computeStyleInvalidations } from './style-invalidations.js';
|
|
21
|
+
export { computeSelectorStats } from './selector-stats.js';
|
|
22
|
+
export { computeTimerCosts } from './timer-costs.js';
|
|
23
|
+
export { computeBlockingTime } from './blocking-time.js';
|
|
24
|
+
export { computeDomainBreakdown } from './domain-breakdown.js';
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Compute the hierarchical main-thread task tree for a trace.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module CPU attribution for script bundles that concatenate many
|
|
3
|
+
* logical modules into a single URL (e.g. MediaWiki ResourceLoader
|
|
4
|
+
* load.php responses). cpu.urls can only say "load.php used 267 ms of
|
|
5
|
+
* main thread"; this analysis splits that time across the modules
|
|
6
|
+
* inside each bundle so the owner of a slow module can be identified.
|
|
7
|
+
*
|
|
8
|
+
* This file is bundle-format agnostic. It walks the main-thread task
|
|
9
|
+
* tree and, for every task whose most-specific code location falls
|
|
10
|
+
* inside a known bundle URL, asks that bundle's resolver which module
|
|
11
|
+
* owns the source position. All knowledge about the bundle format
|
|
12
|
+
* (module delimiters, line/column → byte-offset math) lives in the
|
|
13
|
+
* resolver — see lib/chrome/mediawikiResourceLoader.js.
|
|
14
|
+
*
|
|
15
|
+
* Attribution rules per task (selfTime is exclusive of children, so
|
|
16
|
+
* nothing is counted twice):
|
|
17
|
+
* - EvaluateScript / v8.compile / v8.compileModule on a bundle URL:
|
|
18
|
+
* the event spans the whole script, not one module (module bodies
|
|
19
|
+
* are only registered at evaluate time and executed later), so the
|
|
20
|
+
* time goes to the bundle's `otherTime` bucket.
|
|
21
|
+
* - FunctionCall: args.data carries the definition site of the
|
|
22
|
+
* invoked function — resolve it to a module.
|
|
23
|
+
* - Any other event with a stack trace: the innermost frame with a
|
|
24
|
+
* URL decides — resolve when it is a bundle, skip the task when it
|
|
25
|
+
* points at code outside any bundle.
|
|
26
|
+
* - Tasks with no location of their own (layout, GC, microtasks
|
|
27
|
+
* triggered by module code) fall back to the inherited
|
|
28
|
+
* most-specific attributable URL; when that is a bundle the time
|
|
29
|
+
* is in-bundle but not attributable to one module → `otherTime`.
|
|
30
|
+
*
|
|
31
|
+
* Because only a task's own most-specific location (or the inherited
|
|
32
|
+
* chain when it has none) is credited, the per-bundle sum of module
|
|
33
|
+
* selfTime + otherTime is always ≤ that URL's total in cpu.urls,
|
|
34
|
+
* which counts a task for every URL anywhere in its chain.
|
|
35
|
+
*
|
|
36
|
+
* Times are in ms rounded to one decimal, matching the sibling
|
|
37
|
+
* analyses. Modules that round to 0 are dropped; bundles are sorted
|
|
38
|
+
* by total time desc and modules by selfTime desc.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { compute } from './main-thread-tasks.js';
|
|
42
|
+
|
|
43
|
+
const WHOLE_SCRIPT_EVENTS = new Set([
|
|
44
|
+
'EvaluateScript',
|
|
45
|
+
'v8.compile',
|
|
46
|
+
'v8.compileModule'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function round(ms) {
|
|
50
|
+
return Math.round(ms * 10) / 10;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function attributeTask(task, bundles) {
|
|
54
|
+
const event = task.event;
|
|
55
|
+
const argsData = (event.args && event.args.data) || {};
|
|
56
|
+
|
|
57
|
+
if (WHOLE_SCRIPT_EVENTS.has(event.name)) {
|
|
58
|
+
const url =
|
|
59
|
+
event.name === 'v8.compileModule' ? event.args.fileName : argsData.url;
|
|
60
|
+
return url && bundles.has(url) ? { url } : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (event.name === 'FunctionCall' && argsData.url) {
|
|
64
|
+
const bundle = bundles.get(argsData.url);
|
|
65
|
+
if (!bundle) return;
|
|
66
|
+
return {
|
|
67
|
+
url: argsData.url,
|
|
68
|
+
module: bundle.resolve(argsData.lineNumber, argsData.columnNumber)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const frame of argsData.stackTrace || []) {
|
|
73
|
+
if (!frame.url) continue;
|
|
74
|
+
const bundle = bundles.get(frame.url);
|
|
75
|
+
if (!bundle) return;
|
|
76
|
+
return {
|
|
77
|
+
url: frame.url,
|
|
78
|
+
module: bundle.resolve(frame.lineNumber, frame.columnNumber)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inherited = task.attributableURLs.at(-1);
|
|
83
|
+
return inherited && bundles.has(inherited) ? { url: inherited } : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {Object} trace Parsed Chrome trace.json (with .traceEvents).
|
|
88
|
+
* @param {Map<string, {resolve: Function}>} bundles Bundle URL →
|
|
89
|
+
* location resolver, built while collecting coverage.
|
|
90
|
+
* @returns {Array<{url:string,
|
|
91
|
+
* modules:Array<{name:string, version:string, selfTime:number}>,
|
|
92
|
+
* otherTime:number}>}
|
|
93
|
+
*/
|
|
94
|
+
export function computeModuleCosts(trace, bundles) {
|
|
95
|
+
if (!bundles || bundles.size === 0) return [];
|
|
96
|
+
|
|
97
|
+
const byBundle = new Map();
|
|
98
|
+
for (const task of compute(trace)) {
|
|
99
|
+
if (!(task.selfTime > 0)) continue;
|
|
100
|
+
const attribution = attributeTask(task, bundles);
|
|
101
|
+
if (!attribution) continue;
|
|
102
|
+
|
|
103
|
+
let entry = byBundle.get(attribution.url);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = { byModule: new Map(), otherTime: 0 };
|
|
106
|
+
byBundle.set(attribution.url, entry);
|
|
107
|
+
}
|
|
108
|
+
const module = attribution.module;
|
|
109
|
+
if (module) {
|
|
110
|
+
const key = `${module.name}@${module.version}`;
|
|
111
|
+
const moduleEntry = entry.byModule.get(key);
|
|
112
|
+
if (moduleEntry) {
|
|
113
|
+
moduleEntry.selfTime += task.selfTime;
|
|
114
|
+
} else {
|
|
115
|
+
entry.byModule.set(key, { ...module, selfTime: task.selfTime });
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
entry.otherTime += task.selfTime;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const [url, entry] of byBundle) {
|
|
124
|
+
const modules = [];
|
|
125
|
+
let total = entry.otherTime;
|
|
126
|
+
for (const moduleEntry of entry.byModule.values()) {
|
|
127
|
+
total += moduleEntry.selfTime;
|
|
128
|
+
moduleEntry.selfTime = round(moduleEntry.selfTime);
|
|
129
|
+
if (moduleEntry.selfTime > 0) modules.push(moduleEntry);
|
|
130
|
+
}
|
|
131
|
+
modules.sort((a, b) => b.selfTime - a.selfTime);
|
|
132
|
+
result.push({ url, modules, otherTime: round(entry.otherTime), total });
|
|
133
|
+
}
|
|
134
|
+
result.sort((a, b) => b.total - a.total);
|
|
135
|
+
for (const entry of result) delete entry.total;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
@@ -10,20 +10,64 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Chrome marks these in the trace via `Animation` events whose
|
|
12
12
|
* `args.data.compositeFailed` bitmask is non-zero, plus a list of
|
|
13
|
-
* `unsupportedProperties` strings.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `unsupportedProperties` strings. Chrome often omits the property
|
|
14
|
+
* list (the bitmask is the only signal), so the bitmask is decoded
|
|
15
|
+
* into `failureReasons` strings here — otherwise consumers show "an
|
|
16
|
+
* animation failed" with no way to say why.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* The bit meanings come from Blink's CompositorAnimations
|
|
19
|
+
* FailureReason enum (compositor_animations.h, kFailureReasonCount
|
|
20
|
+
* = 21 as of Chrome ~M140): bit values are stable and never reused,
|
|
21
|
+
* so decoding old traces is safe. Bits 8/11/14 are retired in
|
|
22
|
+
* current Blink but kept here for traces from older Chrome versions.
|
|
23
|
+
* Unknown future bits decode to 'unknown reason (bit N)' instead of
|
|
24
|
+
* being dropped.
|
|
25
|
+
*
|
|
26
|
+
* Returns: [{ name, id, compositeFailed, failureReasons,
|
|
27
|
+
* unsupportedProperties, startTime }, …]
|
|
19
28
|
* name — args.data.nodeName when present (rare)
|
|
20
29
|
* id — args.data.id (compositor-internal)
|
|
21
30
|
* compositeFailed — bitmask of failure reasons
|
|
31
|
+
* failureReasons — the bitmask decoded to human-readable strings
|
|
22
32
|
* unsupportedProperties — array of property names that blocked
|
|
23
33
|
* composite (e.g. ['top','box-shadow'])
|
|
24
34
|
* startTime — event ts in microseconds (raw trace timestamp)
|
|
25
35
|
*/
|
|
26
36
|
|
|
37
|
+
// Blink FailureReason bit → human-readable string, in bit order.
|
|
38
|
+
const FAILURE_REASONS = [
|
|
39
|
+
'accelerated animations disabled', // 1 << 0
|
|
40
|
+
'effect suppressed by DevTools', // 1 << 1
|
|
41
|
+
'invalid animation or effect', // 1 << 2
|
|
42
|
+
'effect has unsupported timing parameters', // 1 << 3
|
|
43
|
+
"effect has composite mode other than 'replace'", // 1 << 4
|
|
44
|
+
'target has invalid compositing state', // 1 << 5
|
|
45
|
+
'target has another incompatible animation', // 1 << 6
|
|
46
|
+
'target has CSS offset', // 1 << 7
|
|
47
|
+
'target has multiple transform properties', // 1 << 8 (retired)
|
|
48
|
+
'animation affects non-CSS properties', // 1 << 9
|
|
49
|
+
'transform-related property cannot be accelerated on target', // 1 << 10
|
|
50
|
+
'transform-related property depends on box size', // 1 << 11 (retired)
|
|
51
|
+
'filter-related property may move pixels', // 1 << 12
|
|
52
|
+
'unsupported CSS property', // 1 << 13
|
|
53
|
+
'multiple transform animations on same target', // 1 << 14 (retired)
|
|
54
|
+
'mixed keyframe value types', // 1 << 15
|
|
55
|
+
'scroll timeline source has invalid compositing state', // 1 << 16
|
|
56
|
+
'animation has no visible change', // 1 << 17
|
|
57
|
+
'animation affects an !important property', // 1 << 18
|
|
58
|
+
'SVG target has independent transform property', // 1 << 19
|
|
59
|
+
"effect has iteration composite mode other than 'replace'" // 1 << 20
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function decodeFailureReasons(bitmask) {
|
|
63
|
+
const reasons = [];
|
|
64
|
+
for (let bit = 0; bitmask >> bit > 0; bit++) {
|
|
65
|
+
if (((bitmask >> bit) & 1) === 0) continue;
|
|
66
|
+
reasons.push(FAILURE_REASONS[bit] || `unknown reason (bit ${bit})`);
|
|
67
|
+
}
|
|
68
|
+
return reasons;
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
export function computeNonCompositedAnimations(trace) {
|
|
28
72
|
const seen = new Set();
|
|
29
73
|
const animations = [];
|
|
@@ -46,6 +90,7 @@ export function computeNonCompositedAnimations(trace) {
|
|
|
46
90
|
name: data.nodeName || '',
|
|
47
91
|
id: data.id || '',
|
|
48
92
|
compositeFailed: failed,
|
|
93
|
+
failureReasons: decodeFailureReasons(failed),
|
|
49
94
|
unsupportedProperties: unsupported,
|
|
50
95
|
startTime: event.ts
|
|
51
96
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS selector matching cost, from Chrome's SelectorStats trace
|
|
3
|
+
* events (the disabled-by-default-blink.debug category, enabled on
|
|
4
|
+
* --enableProfileRun). For every style recalculation Chrome reports
|
|
5
|
+
* per-selector elapsed time, match attempts and match count — the
|
|
6
|
+
* same data DevTools' "CSS selector stats" checkbox shows. Style &
|
|
7
|
+
* layout often dominates main-thread blocking, and this is the only
|
|
8
|
+
* signal that says WHICH selectors the recalc time goes to.
|
|
9
|
+
*
|
|
10
|
+
* Selectors are aggregated across all recalcs in the trace. Two
|
|
11
|
+
* rankings matter and both are kept: elapsed time (what costs now)
|
|
12
|
+
* and match attempts with zero matches (pure waste — selectors
|
|
13
|
+
* evaluated against the whole document for rules that never apply,
|
|
14
|
+
* e.g. a lightbox selector on pages where the lightbox never opens).
|
|
15
|
+
*
|
|
16
|
+
* `styleSheetId` is Chrome's session-internal stylesheet handle —
|
|
17
|
+
* kept so consumers that also collect CSS coverage over CDP can join
|
|
18
|
+
* back to a stylesheet URL, but it is not stable across runs.
|
|
19
|
+
* 'ua-style-sheet' marks Chrome's own user-agent rules; those are
|
|
20
|
+
* not the page's to fix and are excluded from the reported lists
|
|
21
|
+
* (their time still counts in the totals).
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined when the trace has no SelectorStats events (the
|
|
24
|
+
* category is only on for profile runs). Otherwise:
|
|
25
|
+
* { totalElapsed, totalMatchAttempts, totalMatchCount,
|
|
26
|
+
* selectors: [{ selector, styleSheetId?, elapsed,
|
|
27
|
+
* matchAttempts, matchCount }, …],
|
|
28
|
+
* wastedSelectors: [{ … same shape, matchCount always 0 }, …] }
|
|
29
|
+
* elapsed in ms with two decimals (individual selectors are sub-ms),
|
|
30
|
+
* both lists capped at 25 entries; selectors sorted by elapsed desc,
|
|
31
|
+
* wastedSelectors by matchAttempts desc.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const MAX_SELECTORS = 25;
|
|
35
|
+
const UA_STYLE_SHEET = 'ua-style-sheet';
|
|
36
|
+
|
|
37
|
+
function round2(ms) {
|
|
38
|
+
return Math.round(ms * 100) / 100;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeSelectorStats(trace) {
|
|
42
|
+
const bySelector = new Map();
|
|
43
|
+
let sawSelectorStats = false;
|
|
44
|
+
let totalElapsedUs = 0;
|
|
45
|
+
let totalMatchAttempts = 0;
|
|
46
|
+
let totalMatchCount = 0;
|
|
47
|
+
|
|
48
|
+
for (const event of trace.traceEvents) {
|
|
49
|
+
if (event.name !== 'SelectorStats') continue;
|
|
50
|
+
sawSelectorStats = true;
|
|
51
|
+
const timings =
|
|
52
|
+
(event.args &&
|
|
53
|
+
event.args.selector_stats &&
|
|
54
|
+
event.args.selector_stats.selector_timings) ||
|
|
55
|
+
[];
|
|
56
|
+
for (const timing of timings) {
|
|
57
|
+
const elapsedUs = timing['elapsed (us)'] || 0;
|
|
58
|
+
totalElapsedUs += elapsedUs;
|
|
59
|
+
totalMatchAttempts += timing.match_attempts || 0;
|
|
60
|
+
totalMatchCount += timing.match_count || 0;
|
|
61
|
+
if (timing.style_sheet_id === UA_STYLE_SHEET) continue;
|
|
62
|
+
const key = `${timing.selector}|${timing.style_sheet_id || ''}`;
|
|
63
|
+
let entry = bySelector.get(key);
|
|
64
|
+
if (!entry) {
|
|
65
|
+
entry = {
|
|
66
|
+
selector: timing.selector,
|
|
67
|
+
styleSheetId: timing.style_sheet_id,
|
|
68
|
+
elapsedUs: 0,
|
|
69
|
+
matchAttempts: 0,
|
|
70
|
+
matchCount: 0
|
|
71
|
+
};
|
|
72
|
+
bySelector.set(key, entry);
|
|
73
|
+
}
|
|
74
|
+
entry.elapsedUs += elapsedUs;
|
|
75
|
+
entry.matchAttempts += timing.match_attempts || 0;
|
|
76
|
+
entry.matchCount += timing.match_count || 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!sawSelectorStats) return;
|
|
80
|
+
|
|
81
|
+
function publish(entry) {
|
|
82
|
+
const result = {
|
|
83
|
+
selector: entry.selector,
|
|
84
|
+
elapsed: round2(entry.elapsedUs / 1000),
|
|
85
|
+
matchAttempts: entry.matchAttempts,
|
|
86
|
+
matchCount: entry.matchCount
|
|
87
|
+
};
|
|
88
|
+
if (entry.styleSheetId) result.styleSheetId = entry.styleSheetId;
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const all = [...bySelector.values()];
|
|
93
|
+
const selectors = all
|
|
94
|
+
.toSorted((a, b) => b.elapsedUs - a.elapsedUs)
|
|
95
|
+
.slice(0, MAX_SELECTORS)
|
|
96
|
+
.map(entry => publish(entry));
|
|
97
|
+
const wastedSelectors = all
|
|
98
|
+
.filter(entry => entry.matchCount === 0)
|
|
99
|
+
.toSorted((a, b) => b.matchAttempts - a.matchAttempts)
|
|
100
|
+
.slice(0, MAX_SELECTORS)
|
|
101
|
+
.map(entry => publish(entry));
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
totalElapsed: round2(totalElapsedUs / 1000),
|
|
105
|
+
totalMatchAttempts,
|
|
106
|
+
totalMatchCount,
|
|
107
|
+
selectors,
|
|
108
|
+
wastedSelectors
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style/layout invalidation attribution, from the
|
|
3
|
+
* devtools.timeline.invalidationTracking events browsertime has
|
|
4
|
+
* always collected with --chrome.timeline but never parsed. The
|
|
5
|
+
* recalculate-style and layout totals say HOW MUCH invalidation work
|
|
6
|
+
* happened; these events say WHY and WHO:
|
|
7
|
+
*
|
|
8
|
+
* - StyleRecalcInvalidationTracking: one event per invalidated node
|
|
9
|
+
* with the reason ("Style rule change", "Node was inserted into
|
|
10
|
+
* tree", "Affected by :has()", …) and often the JS stack that
|
|
11
|
+
* caused it.
|
|
12
|
+
* - ScheduleStyleInvalidationTracking: the DOM change that scheduled
|
|
13
|
+
* an invalidation — which class/attribute/id/pseudo changed on
|
|
14
|
+
* which node.
|
|
15
|
+
* - LayoutInvalidationTracking: one event per node whose layout was
|
|
16
|
+
* invalidated, with reason and often a stack.
|
|
17
|
+
*
|
|
18
|
+
* Aggregated three ways: reason counts (what kind of churn),
|
|
19
|
+
* trigger counts (which class/attribute names keep changing) and
|
|
20
|
+
* source counts (which script did it, from the innermost stack frame
|
|
21
|
+
* with a URL). One noisy script toggling a class on N nodes shows up
|
|
22
|
+
* as N invalidations from that URL with that class as the trigger —
|
|
23
|
+
* exactly the row a developer can act on.
|
|
24
|
+
*
|
|
25
|
+
* Counts, not milliseconds: the events are instant markers, the time
|
|
26
|
+
* they cause is already in cpu.categories.styleLayout and the
|
|
27
|
+
* recalculateStyle summaries.
|
|
28
|
+
*
|
|
29
|
+
* When the optional `bundles` map (url → location resolver from the
|
|
30
|
+
* coverage path) is passed, source frames inside concatenated
|
|
31
|
+
* bundles resolve to the owning module — thousands of invalidations
|
|
32
|
+
* from one giant load.php URL split into per-module rows.
|
|
33
|
+
*
|
|
34
|
+
* Returns undefined when the trace has no invalidation events.
|
|
35
|
+
* Otherwise:
|
|
36
|
+
* { styleRecalcs, layoutInvalidations,
|
|
37
|
+
* recalcReasons: [{ reason, count }, …],
|
|
38
|
+
* layoutReasons: [{ reason, count }, …],
|
|
39
|
+
* triggers: [{ kind, name, count }, …],
|
|
40
|
+
* sources: [{ url, module?, count }, …] }
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const MAX_REASONS = 10;
|
|
44
|
+
const MAX_TRIGGERS = 15;
|
|
45
|
+
const MAX_SOURCES = 10;
|
|
46
|
+
|
|
47
|
+
function increment(map, key) {
|
|
48
|
+
if (!key) return;
|
|
49
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Source identity for an invalidation: the innermost stack frame
|
|
53
|
+
// with a URL, resolved to the owning module when the frame lands in
|
|
54
|
+
// a known concatenated bundle (trace stack-frame positions are
|
|
55
|
+
// 1-based, matching the resolvers). Without bundles the identity is
|
|
56
|
+
// just the URL — the pre-existing behaviour.
|
|
57
|
+
function sourceEntryFor(sources, data, bundles) {
|
|
58
|
+
for (const frame of data.stackTrace || []) {
|
|
59
|
+
if (!frame.url) continue;
|
|
60
|
+
let module_;
|
|
61
|
+
if (bundles && frame.lineNumber !== undefined) {
|
|
62
|
+
const resolver = bundles.get(frame.url);
|
|
63
|
+
if (resolver) {
|
|
64
|
+
module_ = resolver.resolve(frame.lineNumber, frame.columnNumber);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const key = module_ ? `${frame.url}|${module_.name}` : frame.url;
|
|
68
|
+
let entry = sources.get(key);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
entry = { url: frame.url, count: 0 };
|
|
71
|
+
if (module_) entry.module = module_.name;
|
|
72
|
+
sources.set(key, entry);
|
|
73
|
+
}
|
|
74
|
+
entry.count++;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function topEntries(map, limit, publish) {
|
|
80
|
+
return [...map.entries()]
|
|
81
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
82
|
+
.slice(0, limit)
|
|
83
|
+
.map(entry => publish(entry));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function computeStyleInvalidations(trace, bundles) {
|
|
87
|
+
const recalcReasons = new Map();
|
|
88
|
+
const layoutReasons = new Map();
|
|
89
|
+
const triggers = new Map();
|
|
90
|
+
const sources = new Map();
|
|
91
|
+
let styleRecalcs = 0;
|
|
92
|
+
let layoutInvalidations = 0;
|
|
93
|
+
let sawInvalidations = false;
|
|
94
|
+
|
|
95
|
+
for (const event of trace.traceEvents) {
|
|
96
|
+
const data = (event.args && event.args.data) || {};
|
|
97
|
+
switch (event.name) {
|
|
98
|
+
case 'StyleRecalcInvalidationTracking': {
|
|
99
|
+
sawInvalidations = true;
|
|
100
|
+
styleRecalcs++;
|
|
101
|
+
increment(recalcReasons, data.reason);
|
|
102
|
+
sourceEntryFor(sources, data, bundles);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'LayoutInvalidationTracking': {
|
|
106
|
+
sawInvalidations = true;
|
|
107
|
+
layoutInvalidations++;
|
|
108
|
+
increment(layoutReasons, data.reason);
|
|
109
|
+
sourceEntryFor(sources, data, bundles);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'ScheduleStyleInvalidationTracking': {
|
|
113
|
+
sawInvalidations = true;
|
|
114
|
+
if (data.changedClass) {
|
|
115
|
+
increment(triggers, `class|${data.changedClass}`);
|
|
116
|
+
}
|
|
117
|
+
if (data.changedAttribute) {
|
|
118
|
+
increment(triggers, `attribute|${data.changedAttribute}`);
|
|
119
|
+
}
|
|
120
|
+
if (data.changedId) {
|
|
121
|
+
increment(triggers, `id|${data.changedId}`);
|
|
122
|
+
}
|
|
123
|
+
if (data.changedPseudo) {
|
|
124
|
+
increment(triggers, `pseudo|${data.changedPseudo}`);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default: {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!sawInvalidations) return;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
styleRecalcs,
|
|
137
|
+
layoutInvalidations,
|
|
138
|
+
recalcReasons: topEntries(
|
|
139
|
+
recalcReasons,
|
|
140
|
+
MAX_REASONS,
|
|
141
|
+
([reason, count]) => ({
|
|
142
|
+
reason,
|
|
143
|
+
count
|
|
144
|
+
})
|
|
145
|
+
),
|
|
146
|
+
layoutReasons: topEntries(
|
|
147
|
+
layoutReasons,
|
|
148
|
+
MAX_REASONS,
|
|
149
|
+
([reason, count]) => ({
|
|
150
|
+
reason,
|
|
151
|
+
count
|
|
152
|
+
})
|
|
153
|
+
),
|
|
154
|
+
triggers: topEntries(triggers, MAX_TRIGGERS, ([key, count]) => {
|
|
155
|
+
const [kind, ...name] = key.split('|');
|
|
156
|
+
return { kind, name: name.join('|'), count };
|
|
157
|
+
}),
|
|
158
|
+
sources: [...sources.values()]
|
|
159
|
+
.toSorted((a, b) => b.count - a.count)
|
|
160
|
+
.slice(0, MAX_SOURCES)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -43,7 +43,26 @@ export const taskGroups = {
|
|
|
43
43
|
'ResourceReceiveResponse',
|
|
44
44
|
'ResourceReceivedData',
|
|
45
45
|
'ResourceReceivedResponse',
|
|
46
|
-
'ResourceFinish'
|
|
46
|
+
'ResourceFinish',
|
|
47
|
+
'ResourceChangePriority',
|
|
48
|
+
// Document/navigation body loading internals (sampled from real
|
|
49
|
+
// Wikipedia traces, July 2026) — the main-thread side of
|
|
50
|
+
// streaming the document into the parser. Same rationale as the
|
|
51
|
+
// resource lifecycle events above.
|
|
52
|
+
'DocumentLoader::DocumentLoader',
|
|
53
|
+
'DocumentLoader::HandleData',
|
|
54
|
+
'DocumentLoader::CommitData',
|
|
55
|
+
'DocumentLoader::BodyDataReceivedImpl',
|
|
56
|
+
'DocumentLoader::BodyLoadingFinished',
|
|
57
|
+
'DocumentLoader::FinishedLoading',
|
|
58
|
+
'NavigationBodyLoader::OnReadable',
|
|
59
|
+
'NavigationBodyLoader::ReadFromDataPipe',
|
|
60
|
+
'URLLoader::Context::OnReceivedResponse',
|
|
61
|
+
'URLLoader::Context::OnCompletedRequest',
|
|
62
|
+
'URLLoader::Context::Cancel',
|
|
63
|
+
'ThrottlingURLLoader::OnReceiveResponse',
|
|
64
|
+
'ResourceRequestSender::OnReceivedResponse',
|
|
65
|
+
'ResourceRequestSender::OnRequestComplete'
|
|
47
66
|
]
|
|
48
67
|
},
|
|
49
68
|
styleLayout: {
|
|
@@ -129,7 +148,14 @@ export const taskGroups = {
|
|
|
129
148
|
'v8.callFunction',
|
|
130
149
|
'v8.callModuleMethod',
|
|
131
150
|
'XHRLoad',
|
|
132
|
-
'XHRReadyStateChange'
|
|
151
|
+
'XHRReadyStateChange',
|
|
152
|
+
// V8 housekeeping that only runs because JS is executing —
|
|
153
|
+
// interrupt handling, stack guards, constructor instantiation.
|
|
154
|
+
'v8.newInstance',
|
|
155
|
+
'V8.InvokeApiInterruptCallbacks',
|
|
156
|
+
'V8.HandleInterrupts',
|
|
157
|
+
'V8.BytecodeBudgetInterrupt',
|
|
158
|
+
'V8.StackGuard'
|
|
133
159
|
]
|
|
134
160
|
},
|
|
135
161
|
garbageCollection: {
|