webperf-snippets 0.2.0 → 0.4.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/README.md +142 -14
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +86 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +74 -11
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// DOM Size and Depth
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
// Lighthouse "Avoid an excessive DOM size" thresholds
|
|
6
|
+
const THRESHOLDS = { warnNodes: 800, errorNodes: 1400, maxDepth: 32, maxChildren: 60 };
|
|
7
|
+
const MAX_ITEMS = 50;
|
|
8
|
+
const SELECTOR_LEVELS = 4;
|
|
9
|
+
|
|
10
|
+
// Short selector for reporting: stops at the nearest id, at most SELECTOR_LEVELS levels
|
|
11
|
+
// up, and marks open shadow root boundaries with ">>>". Never returns DOM nodes.
|
|
12
|
+
const selectorFor = (el) => {
|
|
13
|
+
const parts = [];
|
|
14
|
+
let node = el;
|
|
15
|
+
let truncated = false;
|
|
16
|
+
while (node && node.nodeType === 1) {
|
|
17
|
+
if (parts.length === SELECTOR_LEVELS) {
|
|
18
|
+
truncated = true;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
let part = node.localName;
|
|
22
|
+
if (node.id) {
|
|
23
|
+
parts.unshift(`${part}#${window.CSS.escape(node.id)}`);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
const cls = typeof node.className === "string" ? node.className.trim().split(/\s+/)[0] : "";
|
|
27
|
+
if (cls) part += `.${window.CSS.escape(cls)}`;
|
|
28
|
+
parts.unshift(part);
|
|
29
|
+
const parent = node.parentNode;
|
|
30
|
+
if (parent && parent.nodeType === 11 && parent.host) {
|
|
31
|
+
parts.unshift(">>>");
|
|
32
|
+
node = parent.host;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
node = node.parentElement;
|
|
36
|
+
}
|
|
37
|
+
return (truncated ? "... > " : "") + parts.join(" > ").replace(/> >>> >/g, ">>>");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Iterative walk (explicit stack) so very deep trees cannot overflow the call stack.
|
|
41
|
+
// Depth counts <html> as level 1 and continues through open shadow roots.
|
|
42
|
+
let totalElements = 0;
|
|
43
|
+
let maxDepth = 0;
|
|
44
|
+
let deepest = null;
|
|
45
|
+
let shadowRoots = 0;
|
|
46
|
+
const parents = [];
|
|
47
|
+
const stack = [document.documentElement, 1];
|
|
48
|
+
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const depth = stack.pop();
|
|
51
|
+
const el = stack.pop();
|
|
52
|
+
totalElements++;
|
|
53
|
+
if (depth > maxDepth) {
|
|
54
|
+
maxDepth = depth;
|
|
55
|
+
deepest = el;
|
|
56
|
+
}
|
|
57
|
+
const roots = [el];
|
|
58
|
+
if (el.shadowRoot) {
|
|
59
|
+
shadowRoots++;
|
|
60
|
+
roots.push(el.shadowRoot);
|
|
61
|
+
}
|
|
62
|
+
for (const root of roots) {
|
|
63
|
+
const kids = root.children;
|
|
64
|
+
if (kids.length) parents.push({ el, root, children: kids.length, depth });
|
|
65
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i], depth + 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ranked = parents
|
|
70
|
+
.sort((a, b) => b.children - a.children || a.depth - b.depth);
|
|
71
|
+
const widest = ranked[0] ?? null;
|
|
72
|
+
const widestSelector = widest ? selectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
|
|
73
|
+
|
|
74
|
+
const items = ranked.slice(0, 10).map((p) => ({
|
|
75
|
+
selector: selectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
|
|
76
|
+
children: p.children,
|
|
77
|
+
depth: p.depth,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const issues = [];
|
|
81
|
+
if (totalElements > THRESHOLDS.errorNodes) {
|
|
82
|
+
issues.push({
|
|
83
|
+
severity: "error",
|
|
84
|
+
message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
|
|
85
|
+
});
|
|
86
|
+
} else if (totalElements > THRESHOLDS.warnNodes) {
|
|
87
|
+
issues.push({
|
|
88
|
+
severity: "warning",
|
|
89
|
+
message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (maxDepth > THRESHOLDS.maxDepth) {
|
|
93
|
+
issues.push({
|
|
94
|
+
severity: "warning",
|
|
95
|
+
message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (widest && widest.children > THRESHOLDS.maxChildren) {
|
|
99
|
+
issues.push({
|
|
100
|
+
severity: "warning",
|
|
101
|
+
message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const deepestElement = deepest ? selectorFor(deepest) : null;
|
|
106
|
+
|
|
107
|
+
// Human output
|
|
108
|
+
console.group("%cDOM size and depth", "font-weight: bold; font-size: 14px;");
|
|
109
|
+
console.log(`Elements: ${totalElements} (warn > ${THRESHOLDS.warnNodes}, error > ${THRESHOLDS.errorNodes})`);
|
|
110
|
+
console.log(`Max depth: ${maxDepth} (limit ${THRESHOLDS.maxDepth}) at ${deepestElement}`);
|
|
111
|
+
if (widest) console.log(`Widest parent: ${widestSelector} with ${widest.children} children (limit ${THRESHOLDS.maxChildren})`);
|
|
112
|
+
console.log(`Open shadow roots traversed: ${shadowRoots}`);
|
|
113
|
+
issues.forEach((i) => console.warn(`${i.severity}: ${i.message}`));
|
|
114
|
+
if (items.length) console.table(items);
|
|
115
|
+
console.groupEnd();
|
|
116
|
+
|
|
117
|
+
// Agent output
|
|
118
|
+
return {
|
|
119
|
+
script: "DOM-Size-and-Depth",
|
|
120
|
+
status: "ok",
|
|
121
|
+
count: items.length,
|
|
122
|
+
issues,
|
|
123
|
+
items: items.slice(0, MAX_ITEMS),
|
|
124
|
+
details: {
|
|
125
|
+
totalElements,
|
|
126
|
+
maxDepth,
|
|
127
|
+
maxChildren: widest ? widest.children : 0,
|
|
128
|
+
widestParent: widestSelector,
|
|
129
|
+
deepestElement,
|
|
130
|
+
shadowRoots,
|
|
131
|
+
thresholds: THRESHOLDS,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
})();
|
|
@@ -179,7 +179,9 @@
|
|
|
179
179
|
script: "Forced-Synchronous-Layout",
|
|
180
180
|
status: "ok",
|
|
181
181
|
count: 0,
|
|
182
|
-
details: {
|
|
182
|
+
details: {},
|
|
183
|
+
items: [],
|
|
184
|
+
issues: [],
|
|
183
185
|
};
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -235,13 +237,37 @@
|
|
|
235
237
|
fastestSinceLastMutationMs: Math.min(
|
|
236
238
|
...fslEvents.map((e) => e.sinceLastMutationMs)
|
|
237
239
|
),
|
|
238
|
-
fslEvents: fslEvents.map(({ property, accessType, element, sinceLastMutationMs }) => ({
|
|
239
|
-
property,
|
|
240
|
-
accessType,
|
|
241
|
-
element,
|
|
242
|
-
sinceLastMutationMs,
|
|
243
|
-
})),
|
|
244
240
|
},
|
|
241
|
+
// One item per property and element, most frequent first (at most 50)
|
|
242
|
+
items: Object.values(
|
|
243
|
+
fslEvents.reduce((acc, e) => {
|
|
244
|
+
const key = `${e.property}|${e.accessType}|${e.element}`;
|
|
245
|
+
if (!acc[key]) {
|
|
246
|
+
acc[key] = {
|
|
247
|
+
property: e.property,
|
|
248
|
+
accessType: e.accessType,
|
|
249
|
+
element: e.element,
|
|
250
|
+
count: 0,
|
|
251
|
+
fastestSinceLastMutationMs: e.sinceLastMutationMs,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
acc[key].count++;
|
|
255
|
+
acc[key].fastestSinceLastMutationMs = Math.min(
|
|
256
|
+
acc[key].fastestSinceLastMutationMs,
|
|
257
|
+
e.sinceLastMutationMs
|
|
258
|
+
);
|
|
259
|
+
return acc;
|
|
260
|
+
}, {})
|
|
261
|
+
)
|
|
262
|
+
.sort((a, b) => b.count - a.count)
|
|
263
|
+
.slice(0, 50),
|
|
264
|
+
issues: Object.entries(byProperty)
|
|
265
|
+
.sort((a, b) => b[1] - a[1])
|
|
266
|
+
.slice(0, 10)
|
|
267
|
+
.map(([property, count]) => ({
|
|
268
|
+
severity: count > 5 ? "error" : "warning",
|
|
269
|
+
message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
|
|
270
|
+
})),
|
|
245
271
|
};
|
|
246
272
|
};
|
|
247
273
|
|
|
@@ -81,7 +81,12 @@
|
|
|
81
81
|
console.log(
|
|
82
82
|
" Interact with the page (click, type, etc.) and call this again."
|
|
83
83
|
);
|
|
84
|
-
return
|
|
84
|
+
return {
|
|
85
|
+
script: "Input-Latency-Breakdown",
|
|
86
|
+
status: "error",
|
|
87
|
+
error: "No interactions recorded yet. Interact with the page (click, type, etc.) and call getInputLatencyBreakdown() again.",
|
|
88
|
+
getDataFn: "getInputLatencyBreakdown",
|
|
89
|
+
};
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
console.group(
|
|
@@ -171,11 +176,50 @@
|
|
|
171
176
|
};
|
|
172
177
|
}
|
|
173
178
|
console.groupEnd();
|
|
179
|
+
|
|
180
|
+
const BOTTLENECK_FIXES = {
|
|
181
|
+
"Input Delay": "break up the long tasks that block the main thread (scheduler.yield(), setTimeout)",
|
|
182
|
+
Processing: "optimize the event handlers or debounce them",
|
|
183
|
+
Presentation: "reduce the DOM changes after the handler and avoid layout thrashing",
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const items = types.map((type) => {
|
|
187
|
+
const b = byEventType[type];
|
|
188
|
+
return {
|
|
189
|
+
type,
|
|
190
|
+
count: b.count,
|
|
191
|
+
p75Ms: Math.round(p75(b.durations)),
|
|
192
|
+
inputDelayMs: Math.round(p75(b.inputDelays)),
|
|
193
|
+
processingMs: Math.round(p75(b.processingTimes)),
|
|
194
|
+
presentationMs: Math.round(p75(b.presentationDelays)),
|
|
195
|
+
rating: valueToRating(p75(b.durations)),
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const issues = types
|
|
200
|
+
.filter((type) => valueToRating(p75(byEventType[type].durations)) !== "good")
|
|
201
|
+
.map((type) => {
|
|
202
|
+
const b = byEventType[type];
|
|
203
|
+
const phases = [
|
|
204
|
+
{ name: "Input Delay", value: p75(b.inputDelays) },
|
|
205
|
+
{ name: "Processing", value: p75(b.processingTimes) },
|
|
206
|
+
{ name: "Presentation", value: p75(b.presentationDelays) },
|
|
207
|
+
];
|
|
208
|
+
const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
|
|
209
|
+
const rating = valueToRating(p75(b.durations));
|
|
210
|
+
return {
|
|
211
|
+
severity: rating === "poor" ? "error" : "warning",
|
|
212
|
+
message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
|
|
174
216
|
return {
|
|
175
217
|
script: "Input-Latency-Breakdown",
|
|
176
218
|
status: "ok",
|
|
177
219
|
count: types.length,
|
|
178
220
|
details: { eventTypes: typeSummary },
|
|
221
|
+
items,
|
|
222
|
+
issues,
|
|
179
223
|
};
|
|
180
224
|
};
|
|
181
225
|
|
|
@@ -199,11 +199,22 @@
|
|
|
199
199
|
p75Ms: Math.round(p75),
|
|
200
200
|
byRating,
|
|
201
201
|
},
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
// Slowest first, at most 50
|
|
203
|
+
items: [...allInteractions]
|
|
204
|
+
.sort((a, b) => b.duration - a.duration)
|
|
205
|
+
.slice(0, 50)
|
|
206
|
+
.map((i) => ({
|
|
207
|
+
type: i.type,
|
|
208
|
+
durationMs: Math.round(i.duration),
|
|
209
|
+
rating: i.rating,
|
|
210
|
+
})),
|
|
211
|
+
issues: [...slowInteractions]
|
|
212
|
+
.sort((a, b) => b.duration - a.duration)
|
|
213
|
+
.slice(0, 10)
|
|
214
|
+
.map((i) => ({
|
|
215
|
+
severity: i.rating === "poor" ? "error" : "warning",
|
|
216
|
+
message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
|
|
217
|
+
})),
|
|
207
218
|
};
|
|
208
219
|
};
|
|
209
220
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Layout Shift Tracking
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
(() => {
|
|
4
|
+
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
|
|
6
|
+
console.warn("⚠️ layout-shift entries are not supported in this browser.");
|
|
7
|
+
return { script: "Layout-Shift-Loading-and-Interaction", status: "unsupported", error: "layout-shift entries not supported in this browser" };
|
|
8
|
+
}
|
|
5
9
|
// CLS thresholds
|
|
6
10
|
const valueToRating = (score) =>
|
|
7
11
|
score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
|
|
@@ -18,11 +22,26 @@
|
|
|
18
22
|
poor: "🔴",
|
|
19
23
|
};
|
|
20
24
|
|
|
21
|
-
// Track all shifts
|
|
25
|
+
// Track all shifts. CLS is the largest session window: shifts less than 1s apart,
|
|
26
|
+
// in a window of at most 5s.
|
|
22
27
|
let totalCLS = 0;
|
|
28
|
+
let sessionValue = 0;
|
|
29
|
+
let sessionFirst = null;
|
|
30
|
+
let sessionLast = null;
|
|
23
31
|
const allShifts = [];
|
|
24
32
|
const elementShifts = new Map(); // Track shifts per element
|
|
25
33
|
|
|
34
|
+
// The five elements that shifted the most (same value at start and in the summary)
|
|
35
|
+
const getTopElements = () =>
|
|
36
|
+
Array.from(elementShifts.entries())
|
|
37
|
+
.sort((a, b) => b[1].totalShift - a[1].totalShift)
|
|
38
|
+
.slice(0, 5)
|
|
39
|
+
.map(([selector, data]) => ({
|
|
40
|
+
selector,
|
|
41
|
+
shiftCount: data.count,
|
|
42
|
+
totalImpact: Math.round(data.totalShift * 10000) / 10000,
|
|
43
|
+
}));
|
|
44
|
+
|
|
26
45
|
const getElementSelector = (element) => {
|
|
27
46
|
if (!element) return "(unknown)";
|
|
28
47
|
if (element.id) return `#${element.id}`;
|
|
@@ -39,7 +58,18 @@
|
|
|
39
58
|
const countedForCLS = !entry.hadRecentInput;
|
|
40
59
|
|
|
41
60
|
if (countedForCLS) {
|
|
42
|
-
|
|
61
|
+
if (
|
|
62
|
+
sessionLast &&
|
|
63
|
+
entry.startTime - sessionLast.startTime < 1000 &&
|
|
64
|
+
entry.startTime - sessionFirst.startTime < 5000
|
|
65
|
+
) {
|
|
66
|
+
sessionValue += entry.value;
|
|
67
|
+
} else {
|
|
68
|
+
sessionValue = entry.value;
|
|
69
|
+
sessionFirst = entry;
|
|
70
|
+
}
|
|
71
|
+
sessionLast = entry;
|
|
72
|
+
totalCLS = Math.max(totalCLS, sessionValue);
|
|
43
73
|
}
|
|
44
74
|
|
|
45
75
|
// Get affected elements
|
|
@@ -81,7 +111,7 @@
|
|
|
81
111
|
`font-weight: bold; color: ${countedForCLS ? clsColor : "#888"};`
|
|
82
112
|
);
|
|
83
113
|
|
|
84
|
-
console.log(`%
|
|
114
|
+
console.log(`%cCurrent CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
|
|
85
115
|
console.log(` Time: ${Math.round(entry.startTime)}ms`);
|
|
86
116
|
console.log(` Had recent input: ${entry.hadRecentInput ? "Yes (excluded from CLS)" : "No"}`);
|
|
87
117
|
|
|
@@ -205,10 +235,7 @@
|
|
|
205
235
|
|
|
206
236
|
console.groupEnd();
|
|
207
237
|
|
|
208
|
-
const topElementsData =
|
|
209
|
-
.sort((a, b) => b[1].totalShift - a[1].totalShift)
|
|
210
|
-
.slice(0, 5)
|
|
211
|
-
.map(([selector, data]) => ({ selector, shiftCount: data.count, totalImpact: Math.round(data.totalShift * 10000) / 10000 }));
|
|
238
|
+
const topElementsData = getTopElements();
|
|
212
239
|
return {
|
|
213
240
|
script: "Layout-Shift-Loading-and-Interaction",
|
|
214
241
|
status: "ok",
|
|
@@ -227,6 +254,10 @@
|
|
|
227
254
|
};
|
|
228
255
|
};
|
|
229
256
|
|
|
257
|
+
// Buffered entries reach the observer callback asynchronously; wait for them so the
|
|
258
|
+
// summary below (and the return value) reflect shifts that happened before this ran.
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
260
|
+
|
|
230
261
|
// Initial message
|
|
231
262
|
const rating = valueToRating(totalCLS);
|
|
232
263
|
const icon = RATING_ICONS[rating];
|
|
@@ -240,11 +271,10 @@
|
|
|
240
271
|
""
|
|
241
272
|
);
|
|
242
273
|
|
|
243
|
-
//
|
|
244
|
-
const clsBufferedSync =
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const excludedSync = performance.getEntriesByType("layout-shift").filter((e) => e.hadRecentInput).length;
|
|
274
|
+
// Return for agent (shifts collected by the buffered observer above)
|
|
275
|
+
const clsBufferedSync = totalCLS;
|
|
276
|
+
const countedSync = allShifts.filter((s) => s.countedForCLS).length;
|
|
277
|
+
const excludedSync = allShifts.length - countedSync;
|
|
248
278
|
const clsRatingSync = valueToRating(clsBufferedSync);
|
|
249
279
|
return {
|
|
250
280
|
script: "Layout-Shift-Loading-and-Interaction",
|
|
@@ -259,6 +289,7 @@
|
|
|
259
289
|
shiftCount: countedSync + excludedSync,
|
|
260
290
|
countedShifts: countedSync,
|
|
261
291
|
excludedShifts: excludedSync,
|
|
292
|
+
topElements: getTopElements(),
|
|
262
293
|
},
|
|
263
294
|
message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
|
|
264
295
|
getDataFn: "getLayoutShiftSummary",
|
|
@@ -354,6 +354,33 @@
|
|
|
354
354
|
return capturedFrames;
|
|
355
355
|
},
|
|
356
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Get a structured summary (the value agents read through getDataFn)
|
|
359
|
+
*/
|
|
360
|
+
getData() {
|
|
361
|
+
const blocking = capturedFrames.filter((f) => f.blockingDuration > 0);
|
|
362
|
+
const worst = capturedFrames.reduce((max, f) => Math.max(max, f.blockingDuration), 0);
|
|
363
|
+
return {
|
|
364
|
+
script: "Long-Animation-Frames-Helpers",
|
|
365
|
+
status: "ok",
|
|
366
|
+
count: capturedFrames.length,
|
|
367
|
+
details: {
|
|
368
|
+
withBlockingTime: blocking.length,
|
|
369
|
+
totalBlockingTimeMs: Math.round(blocking.reduce((sum, f) => sum + f.blockingDuration, 0)),
|
|
370
|
+
worstBlockingMs: Math.round(worst),
|
|
371
|
+
},
|
|
372
|
+
items: [...capturedFrames]
|
|
373
|
+
.sort((a, b) => b.duration - a.duration)
|
|
374
|
+
.slice(0, 20)
|
|
375
|
+
.map((f) => ({
|
|
376
|
+
startTimeMs: Math.round(f.startTime),
|
|
377
|
+
durationMs: Math.round(f.duration),
|
|
378
|
+
blockingDurationMs: Math.round(f.blockingDuration),
|
|
379
|
+
scriptCount: f.scripts.length,
|
|
380
|
+
})),
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
|
|
357
384
|
/**
|
|
358
385
|
* Clear all captured data
|
|
359
386
|
*/
|
|
@@ -392,6 +419,6 @@
|
|
|
392
419
|
script: "Long-Animation-Frames-Helpers",
|
|
393
420
|
status: "tracking",
|
|
394
421
|
message: "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc. Call loafHelpers.getRawData() to get raw frame data.",
|
|
395
|
-
getDataFn: "loafHelpers.
|
|
422
|
+
getDataFn: "loafHelpers.getData",
|
|
396
423
|
};
|
|
397
424
|
})();
|
|
@@ -132,6 +132,14 @@
|
|
|
132
132
|
category: data.category,
|
|
133
133
|
durationMs: Math.round(data.duration),
|
|
134
134
|
count: data.count,
|
|
135
|
+
functions: [...data.functions]
|
|
136
|
+
.sort((a, b) => b.duration - a.duration)
|
|
137
|
+
.slice(0, 5)
|
|
138
|
+
.map((fn) => ({
|
|
139
|
+
name: fn.name,
|
|
140
|
+
invoker: fn.invoker,
|
|
141
|
+
durationMs: Math.round(fn.duration),
|
|
142
|
+
})),
|
|
135
143
|
})),
|
|
136
144
|
};
|
|
137
145
|
})();
|
|
@@ -77,6 +77,27 @@
|
|
|
77
77
|
};
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
// Item returned for agents: the same shape at start and in the summary
|
|
81
|
+
const toItem = (frame) => ({
|
|
82
|
+
startTimeMs: frame.startTime,
|
|
83
|
+
durationMs: frame.duration,
|
|
84
|
+
blockingDurationMs: frame.blockingDuration,
|
|
85
|
+
scripts: [...frame.scripts]
|
|
86
|
+
.sort((a, b) => b.duration - a.duration)
|
|
87
|
+
.slice(0, 10)
|
|
88
|
+
.map((s) => ({
|
|
89
|
+
invoker: s.invoker,
|
|
90
|
+
source: s.source,
|
|
91
|
+
durationMs: s.duration,
|
|
92
|
+
forcedStyleAndLayoutMs: s.forcedStyleAndLayout,
|
|
93
|
+
})),
|
|
94
|
+
});
|
|
95
|
+
const topFrames = (frames) =>
|
|
96
|
+
[...frames]
|
|
97
|
+
.sort((a, b) => b.blockingDuration - a.blockingDuration)
|
|
98
|
+
.slice(0, 20)
|
|
99
|
+
.map(toItem);
|
|
100
|
+
|
|
80
101
|
const overlap = (e1, e2) =>
|
|
81
102
|
e1.startTime < e2.startTime + e2.duration &&
|
|
82
103
|
e2.startTime < e1.startTime + e1.duration;
|
|
@@ -164,7 +185,7 @@
|
|
|
164
185
|
});
|
|
165
186
|
|
|
166
187
|
loafObserver.observe({ type: "long-animation-frame", buffered: true });
|
|
167
|
-
eventObserver.observe({ type: "event", buffered: true });
|
|
188
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
168
189
|
|
|
169
190
|
// Summary function
|
|
170
191
|
window.getLoAFSummary = () => {
|
|
@@ -173,7 +194,13 @@
|
|
|
173
194
|
if (allLoAFs.length === 0) {
|
|
174
195
|
console.log(" No long animation frames recorded.");
|
|
175
196
|
console.groupEnd();
|
|
176
|
-
return
|
|
197
|
+
return {
|
|
198
|
+
script: "Long-Animation-Frames",
|
|
199
|
+
status: "ok",
|
|
200
|
+
count: 0,
|
|
201
|
+
details: { totalLoAFs: 0, withBlockingTime: 0, totalBlockingTimeMs: 0, worstBlockingMs: 0, topScripts: [] },
|
|
202
|
+
items: [],
|
|
203
|
+
};
|
|
177
204
|
}
|
|
178
205
|
|
|
179
206
|
const blocking = allLoAFs.filter((l) => l.blockingDuration > 0);
|
|
@@ -266,6 +293,7 @@
|
|
|
266
293
|
.slice(0, 5)
|
|
267
294
|
.map((s) => ({ invoker: s.invoker, source: s.source, totalDurationMs: Math.round(s.totalDuration), count: s.count })),
|
|
268
295
|
},
|
|
296
|
+
items: topFrames(allLoAFs),
|
|
269
297
|
};
|
|
270
298
|
};
|
|
271
299
|
|
|
@@ -292,6 +320,7 @@
|
|
|
292
320
|
totalBlockingTimeMs: Math.round(totalBlockingSync),
|
|
293
321
|
worstBlockingMs: Math.round(worstBlockingSync),
|
|
294
322
|
},
|
|
323
|
+
items: topFrames(loafBuffered.map(processLoAF)),
|
|
295
324
|
message: "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
|
|
296
325
|
getDataFn: "getLoAFSummary",
|
|
297
326
|
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Long Tasks Tracking
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
(() => {
|
|
4
|
+
(async () => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("longtask")) {
|
|
6
|
+
console.warn("⚠️ longtask entries are not supported in this browser.");
|
|
7
|
+
return { script: "LongTask", status: "unsupported", error: "longtask entries not supported in this browser" };
|
|
8
|
+
}
|
|
5
9
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
10
|
|
|
7
11
|
// Severity helpers
|
|
@@ -14,6 +18,19 @@
|
|
|
14
18
|
|
|
15
19
|
// Track all long tasks
|
|
16
20
|
const allTasks = [];
|
|
21
|
+
|
|
22
|
+
// Item returned for agents: the same shape at start and in the summary. Slowest first, at most 50.
|
|
23
|
+
const toItems = (tasks) =>
|
|
24
|
+
[...tasks]
|
|
25
|
+
.sort((a, b) => b.duration - a.duration)
|
|
26
|
+
.slice(0, 50)
|
|
27
|
+
.map((t) => ({
|
|
28
|
+
startTimeMs: Math.round(t.startTime),
|
|
29
|
+
durationMs: Math.round(t.duration),
|
|
30
|
+
blockingTimeMs: Math.round(t.blockingTime),
|
|
31
|
+
severity: t.severity,
|
|
32
|
+
attribution: t.attribution,
|
|
33
|
+
}));
|
|
17
34
|
let totalBlockingTime = 0;
|
|
18
35
|
|
|
19
36
|
try {
|
|
@@ -79,7 +96,18 @@
|
|
|
79
96
|
console.log(" No long tasks recorded.");
|
|
80
97
|
console.log(" This is good! The main thread has been responsive.");
|
|
81
98
|
console.groupEnd();
|
|
82
|
-
return
|
|
99
|
+
return {
|
|
100
|
+
script: "LongTask",
|
|
101
|
+
status: "ok",
|
|
102
|
+
count: 0,
|
|
103
|
+
details: {
|
|
104
|
+
totalBlockingTimeMs: 0,
|
|
105
|
+
worstTaskMs: 0,
|
|
106
|
+
avgDurationMs: 0,
|
|
107
|
+
bySeverity: { critical: 0, high: 0, medium: 0, low: 0 },
|
|
108
|
+
},
|
|
109
|
+
items: [],
|
|
110
|
+
};
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
const durations = allTasks.map((t) => t.duration);
|
|
@@ -119,7 +147,7 @@
|
|
|
119
147
|
console.log("");
|
|
120
148
|
console.log("%c⏱️ Timeline:", "font-weight: bold;");
|
|
121
149
|
|
|
122
|
-
const tableData = allTasks
|
|
150
|
+
const tableData = [...allTasks]
|
|
123
151
|
.sort((a, b) => b.duration - a.duration)
|
|
124
152
|
.slice(0, 10)
|
|
125
153
|
.map((t) => {
|
|
@@ -160,6 +188,7 @@
|
|
|
160
188
|
avgDurationMs: Math.round(avg),
|
|
161
189
|
bySeverity,
|
|
162
190
|
},
|
|
191
|
+
items: toItems(allTasks),
|
|
163
192
|
};
|
|
164
193
|
};
|
|
165
194
|
|
|
@@ -172,8 +201,10 @@
|
|
|
172
201
|
""
|
|
173
202
|
);
|
|
174
203
|
|
|
175
|
-
//
|
|
176
|
-
|
|
204
|
+
// Return for agent. Chrome only exposes longtask entries through a PerformanceObserver,
|
|
205
|
+
// so wait for the buffered observer above to deliver them.
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
207
|
+
const longtaskBuffered = allTasks;
|
|
177
208
|
const totalBlockingSync = longtaskBuffered.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0);
|
|
178
209
|
const worstTaskSync = longtaskBuffered.length > 0 ? Math.max(...longtaskBuffered.map((t) => t.duration)) : 0;
|
|
179
210
|
const bySeveritySync = {
|
|
@@ -191,6 +222,7 @@
|
|
|
191
222
|
worstTaskMs: Math.round(worstTaskSync),
|
|
192
223
|
bySeverity: bySeveritySync,
|
|
193
224
|
},
|
|
225
|
+
items: toItems(allTasks),
|
|
194
226
|
message: "Tracking long tasks. Call getLongTaskSummary() for statistics.",
|
|
195
227
|
getDataFn: "getLongTaskSummary",
|
|
196
228
|
};
|