browsertime 27.5.0 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +27 -3
  2. package/bin/browsertime.js +28 -4
  3. package/lib/chrome/coverage.js +89 -19
  4. package/lib/chrome/mediawikiResourceLoader.js +223 -0
  5. package/lib/chrome/parseCpuTrace.js +87 -2
  6. package/lib/chrome/trace/blocking-time.js +189 -0
  7. package/lib/chrome/trace/domain-breakdown.js +86 -0
  8. package/lib/chrome/trace/frame-stability.js +135 -0
  9. package/lib/chrome/trace/function-costs.js +281 -0
  10. package/lib/chrome/trace/index.js +7 -0
  11. package/lib/chrome/trace/module-costs.js +137 -0
  12. package/lib/chrome/trace/non-composited-animations.js +50 -5
  13. package/lib/chrome/trace/selector-stats.js +110 -0
  14. package/lib/chrome/trace/style-invalidations.js +162 -0
  15. package/lib/chrome/trace/task-groups.js +28 -2
  16. package/lib/chrome/trace/timer-costs.js +183 -0
  17. package/lib/chrome/webdriver/chromium.js +114 -0
  18. package/lib/chrome/webdriver/traceUtilities.js +11 -1
  19. package/lib/core/engine/collector.js +12 -0
  20. package/lib/core/engine/index.js +6 -0
  21. package/lib/support/cli.js +1 -1
  22. package/lib/support/har/index.js +44 -1
  23. package/package.json +4 -4
  24. package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
  25. package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
  26. package/types/chrome/parseCpuTrace.d.ts +1 -26
  27. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  28. package/types/chrome/trace/blocking-time.d.ts +7 -0
  29. package/types/chrome/trace/blocking-time.d.ts.map +1 -0
  30. package/types/chrome/trace/domain-breakdown.d.ts +11 -0
  31. package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
  32. package/types/chrome/trace/frame-stability.d.ts +11 -0
  33. package/types/chrome/trace/frame-stability.d.ts.map +1 -0
  34. package/types/chrome/trace/function-costs.d.ts +6 -0
  35. package/types/chrome/trace/function-costs.d.ts.map +1 -0
  36. package/types/chrome/trace/index.d.ts +7 -0
  37. package/types/chrome/trace/index.d.ts.map +1 -1
  38. package/types/chrome/trace/module-costs.d.ts +20 -0
  39. package/types/chrome/trace/module-costs.d.ts.map +1 -0
  40. package/types/chrome/trace/non-composited-animations.d.ts +1 -25
  41. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
  42. package/types/chrome/trace/selector-stats.d.ts +8 -0
  43. package/types/chrome/trace/selector-stats.d.ts.map +1 -0
  44. package/types/chrome/trace/style-invalidations.d.ts +9 -0
  45. package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
  46. package/types/chrome/trace/task-groups.d.ts.map +1 -1
  47. package/types/chrome/trace/timer-costs.d.ts +10 -0
  48. package/types/chrome/trace/timer-costs.d.ts.map +1 -0
  49. 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. 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).
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
- * Returns: [{ name, id, compositeFailed, unsupportedProperties,
18
- * startTime }, …]
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: {