webperf-snippets 0.1.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 +139 -0
- package/package.json +54 -0
- package/snippets/CoreWebVitals/CLS.js +88 -0
- package/snippets/CoreWebVitals/INP.js +432 -0
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
- package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
- package/snippets/CoreWebVitals/LCP.js +135 -0
- package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
- package/snippets/Interaction/Interactions.js +220 -0
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
- package/snippets/Interaction/Long-Animation-Frames.js +298 -0
- package/snippets/Interaction/LongTask.js +203 -0
- package/snippets/Interaction/Scroll-Performance.js +359 -0
- package/snippets/Interaction/WORKFLOWS.md +205 -0
- package/snippets/Loading/Back-Forward-Cache.js +473 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
- package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
- package/snippets/Loading/Content-Visibility.js +352 -0
- package/snippets/Loading/Critical-CSS-Detection.js +329 -0
- package/snippets/Loading/Event-Processing-Time.js +179 -0
- package/snippets/Loading/FCP.js +101 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
- package/snippets/Loading/Find-render-blocking-resources.js +161 -0
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
- package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
- package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
- package/snippets/Loading/Priority-Hints-Audit.js +336 -0
- package/snippets/Loading/Resource-Hints-Validation.js +503 -0
- package/snippets/Loading/Resource-Hints.js +400 -0
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
- package/snippets/Loading/Script-Loading.js +332 -0
- package/snippets/Loading/Service-Worker-Analysis.js +192 -0
- package/snippets/Loading/TTFB-Resources.js +115 -0
- package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
- package/snippets/Loading/TTFB.js +37 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
- package/snippets/Loading/WORKFLOWS.md +163 -0
- package/snippets/Media/Image-Element-Audit.js +350 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
- package/snippets/Media/Video-Element-Audit.js +282 -0
- package/snippets/Media/WORKFLOWS.md +190 -0
- package/snippets/README.md +98 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
- package/snippets/Resources/WORKFLOWS.md +173 -0
- package/src/bin.js +229 -0
- package/src/decision-tree.js +19 -0
- package/src/load-snippet.js +23 -0
- package/src/reporters/human.js +179 -0
- package/src/reporters/json.js +3 -0
- package/src/runner.js +61 -0
- package/src/workflows/audit.js +21 -0
- package/src/workflows/cwv.js +10 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Layout Shift Tracking
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
// CLS thresholds
|
|
6
|
+
const valueToRating = (score) =>
|
|
7
|
+
score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
|
|
8
|
+
|
|
9
|
+
const RATING_COLORS = {
|
|
10
|
+
good: "#0CCE6A",
|
|
11
|
+
"needs-improvement": "#FFA400",
|
|
12
|
+
poor: "#FF4E42",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RATING_ICONS = {
|
|
16
|
+
good: "š¢",
|
|
17
|
+
"needs-improvement": "š”",
|
|
18
|
+
poor: "š“",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Track all shifts
|
|
22
|
+
let totalCLS = 0;
|
|
23
|
+
const allShifts = [];
|
|
24
|
+
const elementShifts = new Map(); // Track shifts per element
|
|
25
|
+
|
|
26
|
+
const getElementSelector = (element) => {
|
|
27
|
+
if (!element) return "(unknown)";
|
|
28
|
+
if (element.id) return `#${element.id}`;
|
|
29
|
+
if (element.className && typeof element.className === "string") {
|
|
30
|
+
const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
31
|
+
if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
|
|
32
|
+
}
|
|
33
|
+
return element.tagName?.toLowerCase() || "(unknown)";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const observer = new PerformanceObserver((list) => {
|
|
37
|
+
for (const entry of list.getEntries()) {
|
|
38
|
+
// Only count shifts without recent input for CLS
|
|
39
|
+
const countedForCLS = !entry.hadRecentInput;
|
|
40
|
+
|
|
41
|
+
if (countedForCLS) {
|
|
42
|
+
totalCLS += entry.value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get affected elements
|
|
46
|
+
const sources = entry.sources || [];
|
|
47
|
+
const elements = sources.map((source) => ({
|
|
48
|
+
element: source.node,
|
|
49
|
+
selector: getElementSelector(source.node),
|
|
50
|
+
previousRect: source.previousRect,
|
|
51
|
+
currentRect: source.currentRect,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// Track per-element shifts
|
|
55
|
+
elements.forEach((el) => {
|
|
56
|
+
if (!elementShifts.has(el.selector)) {
|
|
57
|
+
elementShifts.set(el.selector, { count: 0, totalShift: 0, element: el.element });
|
|
58
|
+
}
|
|
59
|
+
const data = elementShifts.get(el.selector);
|
|
60
|
+
data.count++;
|
|
61
|
+
data.totalShift += entry.value;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const shift = {
|
|
65
|
+
value: entry.value,
|
|
66
|
+
countedForCLS,
|
|
67
|
+
elements,
|
|
68
|
+
time: entry.startTime,
|
|
69
|
+
entry,
|
|
70
|
+
};
|
|
71
|
+
allShifts.push(shift);
|
|
72
|
+
|
|
73
|
+
// Log significant shifts (> 0.001)
|
|
74
|
+
if (entry.value > 0.001) {
|
|
75
|
+
const rating = valueToRating(totalCLS);
|
|
76
|
+
const icon = countedForCLS ? RATING_ICONS[rating] : "āŖ";
|
|
77
|
+
const clsColor = RATING_COLORS[rating];
|
|
78
|
+
|
|
79
|
+
console.groupCollapsed(
|
|
80
|
+
`%c${icon} Layout Shift: ${entry.value.toFixed(4)}${countedForCLS ? "" : " (not counted - user input)"}`,
|
|
81
|
+
`font-weight: bold; color: ${countedForCLS ? clsColor : "#888"};`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
console.log(`%cCumulative CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
|
|
85
|
+
console.log(` Time: ${Math.round(entry.startTime)}ms`);
|
|
86
|
+
console.log(` Had recent input: ${entry.hadRecentInput ? "Yes (excluded from CLS)" : "No"}`);
|
|
87
|
+
|
|
88
|
+
if (elements.length > 0) {
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log("%cElements that shifted:", "font-weight: bold;");
|
|
91
|
+
|
|
92
|
+
elements.forEach((el, i) => {
|
|
93
|
+
console.log(` ${i + 1}. ${el.selector}`, el.element);
|
|
94
|
+
if (el.previousRect && el.currentRect) {
|
|
95
|
+
const dx = el.currentRect.x - el.previousRect.x;
|
|
96
|
+
const dy = el.currentRect.y - el.previousRect.y;
|
|
97
|
+
const dw = el.currentRect.width - el.previousRect.width;
|
|
98
|
+
const dh = el.currentRect.height - el.previousRect.height;
|
|
99
|
+
|
|
100
|
+
const changes = [];
|
|
101
|
+
if (Math.abs(dx) > 0) changes.push(`x: ${dx > 0 ? "+" : ""}${Math.round(dx)}px`);
|
|
102
|
+
if (Math.abs(dy) > 0) changes.push(`y: ${dy > 0 ? "+" : ""}${Math.round(dy)}px`);
|
|
103
|
+
if (Math.abs(dw) > 0) changes.push(`width: ${dw > 0 ? "+" : ""}${Math.round(dw)}px`);
|
|
104
|
+
if (Math.abs(dh) > 0) changes.push(`height: ${dh > 0 ? "+" : ""}${Math.round(dh)}px`);
|
|
105
|
+
|
|
106
|
+
if (changes.length > 0) {
|
|
107
|
+
console.log(` Movement: ${changes.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
console.log(" (No source elements captured)");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.groupEnd();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
observer.observe({ type: "layout-shift", buffered: true });
|
|
121
|
+
|
|
122
|
+
// Summary function
|
|
123
|
+
window.getLayoutShiftSummary = () => {
|
|
124
|
+
console.group("%cš Layout Shift Summary", "font-weight: bold; font-size: 14px;");
|
|
125
|
+
|
|
126
|
+
const rating = valueToRating(totalCLS);
|
|
127
|
+
const icon = RATING_ICONS[rating];
|
|
128
|
+
const color = RATING_COLORS[rating];
|
|
129
|
+
|
|
130
|
+
// CLS Score
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log("%cCLS Score:", "font-weight: bold;");
|
|
133
|
+
console.log(
|
|
134
|
+
` ${icon} %c${totalCLS.toFixed(4)} (${rating})`,
|
|
135
|
+
`color: ${color}; font-weight: bold; font-size: 16px;`
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Statistics
|
|
139
|
+
const countedShifts = allShifts.filter((s) => s.countedForCLS);
|
|
140
|
+
const excludedShifts = allShifts.filter((s) => !s.countedForCLS);
|
|
141
|
+
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log("%cStatistics:", "font-weight: bold;");
|
|
144
|
+
console.log(` Total shift events: ${allShifts.length}`);
|
|
145
|
+
console.log(` Counted for CLS: ${countedShifts.length}`);
|
|
146
|
+
console.log(` Excluded (user input): ${excludedShifts.length}`);
|
|
147
|
+
|
|
148
|
+
if (countedShifts.length > 0) {
|
|
149
|
+
const largest = Math.max(...countedShifts.map((s) => s.value));
|
|
150
|
+
console.log(` Largest single shift: ${largest.toFixed(4)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Top shifting elements
|
|
154
|
+
if (elementShifts.size > 0) {
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log("%cšÆ Top Shifting Elements:", "font-weight: bold; color: #ef4444;");
|
|
157
|
+
|
|
158
|
+
const sortedElements = Array.from(elementShifts.entries())
|
|
159
|
+
.sort((a, b) => b[1].totalShift - a[1].totalShift)
|
|
160
|
+
.slice(0, 5);
|
|
161
|
+
|
|
162
|
+
const tableData = sortedElements.map(([selector, data]) => ({
|
|
163
|
+
Element: selector,
|
|
164
|
+
"Shift Count": data.count,
|
|
165
|
+
"Total Impact": data.totalShift.toFixed(4),
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
console.table(tableData);
|
|
169
|
+
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log("%cš Inspect elements:", "font-weight: bold;");
|
|
172
|
+
sortedElements.forEach(([selector, data], i) => {
|
|
173
|
+
console.log(` ${i + 1}. ${selector}`, data.element);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Timeline
|
|
178
|
+
if (countedShifts.length > 0) {
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log("%cā±ļø Shift Timeline:", "font-weight: bold;");
|
|
181
|
+
|
|
182
|
+
const significant = countedShifts.filter((s) => s.value > 0.001);
|
|
183
|
+
if (significant.length > 0) {
|
|
184
|
+
const timelineData = significant.map((s) => ({
|
|
185
|
+
"Time (ms)": Math.round(s.time),
|
|
186
|
+
Value: s.value.toFixed(4),
|
|
187
|
+
Elements: s.elements.map((e) => e.selector).join(", ") || "(unknown)",
|
|
188
|
+
}));
|
|
189
|
+
console.table(timelineData);
|
|
190
|
+
} else {
|
|
191
|
+
console.log(" No significant shifts (all < 0.001)");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Recommendations
|
|
196
|
+
if (rating !== "good") {
|
|
197
|
+
console.log("");
|
|
198
|
+
console.log("%cš” Recommendations:", "font-weight: bold; color: #3b82f6;");
|
|
199
|
+
console.log(" ⢠Add width/height attributes to images and videos");
|
|
200
|
+
console.log(" ⢠Reserve space for ads, embeds, and dynamic content");
|
|
201
|
+
console.log(" ⢠Use CSS aspect-ratio for responsive elements");
|
|
202
|
+
console.log(" ⢠Avoid inserting content above existing content");
|
|
203
|
+
console.log(" ⢠Use transform animations instead of layout-triggering properties");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.groupEnd();
|
|
207
|
+
|
|
208
|
+
const topElementsData = Array.from(elementShifts.entries())
|
|
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 }));
|
|
212
|
+
return {
|
|
213
|
+
script: "Layout-Shift-Loading-and-Interaction",
|
|
214
|
+
status: "ok",
|
|
215
|
+
metric: "CLS",
|
|
216
|
+
value: Math.round(totalCLS * 10000) / 10000,
|
|
217
|
+
unit: "score",
|
|
218
|
+
rating,
|
|
219
|
+
thresholds: { good: 0.1, needsImprovement: 0.25 },
|
|
220
|
+
details: {
|
|
221
|
+
currentCLS: Math.round(totalCLS * 10000) / 10000,
|
|
222
|
+
shiftCount: allShifts.length,
|
|
223
|
+
countedShifts: countedShifts.length,
|
|
224
|
+
excludedShifts: excludedShifts.length,
|
|
225
|
+
topElements: topElementsData,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Initial message
|
|
231
|
+
const rating = valueToRating(totalCLS);
|
|
232
|
+
const icon = RATING_ICONS[rating];
|
|
233
|
+
|
|
234
|
+
console.log("%cš Layout Shift Tracking Active", "font-weight: bold; font-size: 14px;");
|
|
235
|
+
console.log(` Current CLS: ${icon} ${totalCLS.toFixed(4)}`);
|
|
236
|
+
console.log(" Interact with the page to see new shifts.");
|
|
237
|
+
console.log(
|
|
238
|
+
" Call %cgetLayoutShiftSummary()%c for full analysis.",
|
|
239
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
240
|
+
""
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Synchronous return for agent (buffered layout-shift entries)
|
|
244
|
+
const clsBufferedSync = performance.getEntriesByType("layout-shift")
|
|
245
|
+
.reduce((sum, e) => !e.hadRecentInput ? sum + e.value : sum, 0);
|
|
246
|
+
const countedSync = performance.getEntriesByType("layout-shift").filter((e) => !e.hadRecentInput).length;
|
|
247
|
+
const excludedSync = performance.getEntriesByType("layout-shift").filter((e) => e.hadRecentInput).length;
|
|
248
|
+
const clsRatingSync = valueToRating(clsBufferedSync);
|
|
249
|
+
return {
|
|
250
|
+
script: "Layout-Shift-Loading-and-Interaction",
|
|
251
|
+
status: "tracking",
|
|
252
|
+
metric: "CLS",
|
|
253
|
+
value: Math.round(clsBufferedSync * 10000) / 10000,
|
|
254
|
+
unit: "score",
|
|
255
|
+
rating: clsRatingSync,
|
|
256
|
+
thresholds: { good: 0.1, needsImprovement: 0.25 },
|
|
257
|
+
details: {
|
|
258
|
+
currentCLS: Math.round(clsBufferedSync * 10000) / 10000,
|
|
259
|
+
shiftCount: countedSync + excludedSync,
|
|
260
|
+
countedShifts: countedSync,
|
|
261
|
+
excludedShifts: excludedSync,
|
|
262
|
+
},
|
|
263
|
+
message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
|
|
264
|
+
getDataFn: "getLayoutShiftSummary",
|
|
265
|
+
};
|
|
266
|
+
})();
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// LoAF Helpers - Advanced Debugging
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
// Check browser support
|
|
8
|
+
if (
|
|
9
|
+
!("PerformanceObserver" in window) ||
|
|
10
|
+
!PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")
|
|
11
|
+
) {
|
|
12
|
+
console.warn("%cā ļø Long Animation Frames API not supported", "font-weight: bold;");
|
|
13
|
+
console.warn(" Requires Chrome 123+");
|
|
14
|
+
return { script: "Long-Animation-Frames-Helpers", status: "unsupported", error: "Long Animation Frames API not supported. Requires Chrome 123+" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Storage for captured frames
|
|
18
|
+
const capturedFrames = [];
|
|
19
|
+
|
|
20
|
+
// Severity helpers
|
|
21
|
+
const getSeverity = (duration) => {
|
|
22
|
+
if (duration > 200) return { level: "critical", icon: "š“" };
|
|
23
|
+
if (duration > 150) return { level: "high", icon: "š " };
|
|
24
|
+
if (duration > 100) return { level: "medium", icon: "š”" };
|
|
25
|
+
return { level: "low", icon: "š¢" };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Start observing
|
|
29
|
+
const observer = new PerformanceObserver((list) => {
|
|
30
|
+
for (const entry of list.getEntries()) {
|
|
31
|
+
const frameData = {
|
|
32
|
+
startTime: entry.startTime,
|
|
33
|
+
duration: entry.duration,
|
|
34
|
+
renderStart: entry.renderStart,
|
|
35
|
+
styleAndLayoutStart: entry.styleAndLayoutStart,
|
|
36
|
+
firstUIEventTimestamp: entry.firstUIEventTimestamp,
|
|
37
|
+
blockingDuration: entry.blockingDuration,
|
|
38
|
+
scripts: entry.scripts.map((s) => ({
|
|
39
|
+
sourceURL: s.sourceURL || "",
|
|
40
|
+
sourceFunctionName: s.sourceFunctionName || "(anonymous)",
|
|
41
|
+
invoker: s.invoker || "",
|
|
42
|
+
invokerType: s.invokerType || "",
|
|
43
|
+
duration: s.duration,
|
|
44
|
+
executionStart: s.executionStart,
|
|
45
|
+
forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration || 0,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
capturedFrames.push(frameData);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
observer.observe({ type: "long-animation-frame", buffered: true });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("Failed to start LoAF observer:", e);
|
|
56
|
+
return { script: "Long-Animation-Frames-Helpers", status: "error", error: e.message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Helper functions
|
|
60
|
+
window.loafHelpers = {
|
|
61
|
+
/**
|
|
62
|
+
* Show summary of all captured frames
|
|
63
|
+
*/
|
|
64
|
+
summary() {
|
|
65
|
+
if (capturedFrames.length === 0) {
|
|
66
|
+
console.log("%cš No frames captured yet", "font-weight: bold;");
|
|
67
|
+
console.log(" Interact with the page to generate long frames.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const totalTime = capturedFrames.reduce((sum, f) => sum + f.duration, 0);
|
|
72
|
+
const totalBlocking = capturedFrames.reduce((sum, f) => sum + f.blockingDuration, 0);
|
|
73
|
+
const avgDuration = totalTime / capturedFrames.length;
|
|
74
|
+
const maxDuration = Math.max(...capturedFrames.map((f) => f.duration));
|
|
75
|
+
|
|
76
|
+
const severity = {
|
|
77
|
+
critical: capturedFrames.filter((f) => f.duration > 200).length,
|
|
78
|
+
high: capturedFrames.filter((f) => f.duration > 150 && f.duration <= 200).length,
|
|
79
|
+
medium: capturedFrames.filter((f) => f.duration > 100 && f.duration <= 150).length,
|
|
80
|
+
low: capturedFrames.filter((f) => f.duration <= 100).length,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
console.group("%cš LoAF Summary", "font-weight: bold; font-size: 14px;");
|
|
84
|
+
|
|
85
|
+
console.log("");
|
|
86
|
+
console.log("%cStatistics:", "font-weight: bold;");
|
|
87
|
+
console.log(` Total frames: ${capturedFrames.length}`);
|
|
88
|
+
console.log(` Total duration: ${totalTime.toFixed(0)}ms`);
|
|
89
|
+
console.log(` Total blocking: ${totalBlocking.toFixed(0)}ms`);
|
|
90
|
+
console.log(` Average duration: ${avgDuration.toFixed(0)}ms`);
|
|
91
|
+
console.log(` Max duration: ${maxDuration.toFixed(0)}ms`);
|
|
92
|
+
|
|
93
|
+
console.log("");
|
|
94
|
+
console.log("%cBy severity:", "font-weight: bold;");
|
|
95
|
+
console.log(` š“ Critical (>200ms): ${severity.critical}`);
|
|
96
|
+
console.log(` š High (150-200ms): ${severity.high}`);
|
|
97
|
+
console.log(` š” Medium (100-150ms): ${severity.medium}`);
|
|
98
|
+
console.log(` š¢ Low (<100ms): ${severity.low}`);
|
|
99
|
+
|
|
100
|
+
console.groupEnd();
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Show top N slowest scripts
|
|
105
|
+
*/
|
|
106
|
+
topScripts(n = 10) {
|
|
107
|
+
if (capturedFrames.length === 0) {
|
|
108
|
+
console.log("%cš No frames captured yet", "font-weight: bold;");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const allScripts = capturedFrames.flatMap((f) => f.scripts);
|
|
113
|
+
if (allScripts.length === 0) {
|
|
114
|
+
console.log("%cš No scripts found in captured frames", "font-weight: bold;");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Aggregate by source
|
|
119
|
+
const scriptStats = new Map();
|
|
120
|
+
allScripts.forEach((s) => {
|
|
121
|
+
const key = `${s.sourceURL}|${s.sourceFunctionName}`;
|
|
122
|
+
if (!scriptStats.has(key)) {
|
|
123
|
+
scriptStats.set(key, {
|
|
124
|
+
sourceURL: s.sourceURL,
|
|
125
|
+
functionName: s.sourceFunctionName,
|
|
126
|
+
totalDuration: 0,
|
|
127
|
+
count: 0,
|
|
128
|
+
maxDuration: 0,
|
|
129
|
+
totalForcedLayout: 0,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const stats = scriptStats.get(key);
|
|
133
|
+
stats.totalDuration += s.duration;
|
|
134
|
+
stats.count++;
|
|
135
|
+
stats.maxDuration = Math.max(stats.maxDuration, s.duration);
|
|
136
|
+
stats.totalForcedLayout += s.forcedStyleAndLayoutDuration;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const sorted = Array.from(scriptStats.values())
|
|
140
|
+
.sort((a, b) => b.totalDuration - a.totalDuration)
|
|
141
|
+
.slice(0, n);
|
|
142
|
+
|
|
143
|
+
console.group(`%cš Top ${Math.min(n, sorted.length)} Scripts by Total Duration`, "font-weight: bold; font-size: 14px;");
|
|
144
|
+
|
|
145
|
+
const tableData = sorted.map((s) => {
|
|
146
|
+
let path = s.sourceURL;
|
|
147
|
+
try {
|
|
148
|
+
path = new URL(s.sourceURL || location.href).pathname;
|
|
149
|
+
if (path.length > 40) path = "..." + path.slice(-37);
|
|
150
|
+
} catch {}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
Script: path || "(inline)",
|
|
154
|
+
Function: s.functionName.length > 25 ? s.functionName.slice(0, 22) + "..." : s.functionName,
|
|
155
|
+
Count: s.count,
|
|
156
|
+
"Total": `${s.totalDuration.toFixed(0)}ms`,
|
|
157
|
+
"Max": `${s.maxDuration.toFixed(0)}ms`,
|
|
158
|
+
"Forced S&L": s.totalForcedLayout > 0 ? `${s.totalForcedLayout.toFixed(0)}ms` : "-",
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
console.table(tableData);
|
|
163
|
+
console.groupEnd();
|
|
164
|
+
|
|
165
|
+
return sorted;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Filter frames by criteria
|
|
170
|
+
*/
|
|
171
|
+
filter(options = {}) {
|
|
172
|
+
if (capturedFrames.length === 0) {
|
|
173
|
+
console.log("%cš No frames captured yet", "font-weight: bold;");
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let filtered = capturedFrames;
|
|
178
|
+
|
|
179
|
+
if (options.minDuration) {
|
|
180
|
+
filtered = filtered.filter((f) => f.duration >= options.minDuration);
|
|
181
|
+
}
|
|
182
|
+
if (options.maxDuration) {
|
|
183
|
+
filtered = filtered.filter((f) => f.duration <= options.maxDuration);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.group(`%cš Filtered: ${filtered.length} of ${capturedFrames.length} frames`, "font-weight: bold;");
|
|
187
|
+
|
|
188
|
+
if (filtered.length > 0) {
|
|
189
|
+
const tableData = filtered.map((f) => {
|
|
190
|
+
const sev = getSeverity(f.duration);
|
|
191
|
+
return {
|
|
192
|
+
"": sev.icon,
|
|
193
|
+
Start: `${f.startTime.toFixed(0)}ms`,
|
|
194
|
+
Duration: `${f.duration.toFixed(0)}ms`,
|
|
195
|
+
Blocking: `${f.blockingDuration.toFixed(0)}ms`,
|
|
196
|
+
Scripts: f.scripts.length,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
console.table(tableData);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.groupEnd();
|
|
203
|
+
return filtered;
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Find frames containing scripts that match a URL pattern
|
|
208
|
+
*/
|
|
209
|
+
findByURL(search) {
|
|
210
|
+
if (capturedFrames.length === 0) {
|
|
211
|
+
console.log("%cš No frames captured yet", "font-weight: bold;");
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const matches = capturedFrames.filter((f) =>
|
|
216
|
+
f.scripts.some((s) => s.sourceURL.toLowerCase().includes(search.toLowerCase()))
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
console.group(`%cš Found ${matches.length} frames matching "${search}"`, "font-weight: bold;");
|
|
220
|
+
|
|
221
|
+
if (matches.length > 0) {
|
|
222
|
+
const tableData = matches.map((f) => {
|
|
223
|
+
const matchingScript = f.scripts.find((s) =>
|
|
224
|
+
s.sourceURL.toLowerCase().includes(search.toLowerCase())
|
|
225
|
+
);
|
|
226
|
+
let scriptPath = matchingScript.sourceURL;
|
|
227
|
+
try {
|
|
228
|
+
scriptPath = new URL(scriptPath).pathname;
|
|
229
|
+
if (scriptPath.length > 35) scriptPath = "..." + scriptPath.slice(-32);
|
|
230
|
+
} catch {}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"Frame Start": `${f.startTime.toFixed(0)}ms`,
|
|
234
|
+
"Frame Duration": `${f.duration.toFixed(0)}ms`,
|
|
235
|
+
"Script": scriptPath,
|
|
236
|
+
"Script Duration": `${matchingScript.duration.toFixed(0)}ms`,
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
console.table(tableData);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.groupEnd();
|
|
243
|
+
return matches;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Calculate percentiles for RUM reporting
|
|
248
|
+
*/
|
|
249
|
+
percentiles(pcts = [50, 75, 95, 99]) {
|
|
250
|
+
if (capturedFrames.length === 0) {
|
|
251
|
+
console.log("%cš No frames captured yet", "font-weight: bold;");
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const durations = capturedFrames.map((f) => f.duration).sort((a, b) => a - b);
|
|
256
|
+
const result = {};
|
|
257
|
+
|
|
258
|
+
pcts.forEach((p) => {
|
|
259
|
+
const index = Math.ceil((p / 100) * durations.length) - 1;
|
|
260
|
+
const safeIndex = Math.max(0, Math.min(index, durations.length - 1));
|
|
261
|
+
result[`p${p}`] = durations[safeIndex];
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
console.group("%cš Frame Duration Percentiles", "font-weight: bold; font-size: 14px;");
|
|
265
|
+
console.log(` Frames analyzed: ${capturedFrames.length}`);
|
|
266
|
+
console.log("");
|
|
267
|
+
|
|
268
|
+
Object.entries(result).forEach(([key, value]) => {
|
|
269
|
+
const sev = getSeverity(value);
|
|
270
|
+
console.log(` ${sev.icon} ${key.toUpperCase()}: ${value.toFixed(0)}ms`);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
console.log("");
|
|
274
|
+
console.log("%cš” Tip:", "font-weight: bold;");
|
|
275
|
+
console.log(" Use P75 for general performance, P95/P99 for tail latency");
|
|
276
|
+
console.groupEnd();
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Export as JSON
|
|
283
|
+
*/
|
|
284
|
+
exportJSON() {
|
|
285
|
+
if (capturedFrames.length === 0) {
|
|
286
|
+
console.log("%cš No frames to export", "font-weight: bold;");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const data = JSON.stringify(capturedFrames, null, 2);
|
|
291
|
+
const blob = new Blob([data], { type: "application/json" });
|
|
292
|
+
const url = URL.createObjectURL(blob);
|
|
293
|
+
const a = document.createElement("a");
|
|
294
|
+
a.href = url;
|
|
295
|
+
a.download = `loaf-data-${new Date().toISOString().slice(0, 10)}.json`;
|
|
296
|
+
a.click();
|
|
297
|
+
URL.revokeObjectURL(url);
|
|
298
|
+
|
|
299
|
+
console.log(`%cā
Exported ${capturedFrames.length} frames to JSON`, "color: #22c55e; font-weight: bold;");
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Export as CSV
|
|
304
|
+
*/
|
|
305
|
+
exportCSV() {
|
|
306
|
+
if (capturedFrames.length === 0) {
|
|
307
|
+
console.log("%cš No frames to export", "font-weight: bold;");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const rows = [
|
|
312
|
+
["Frame Start", "Duration", "Blocking", "Script URL", "Function", "Script Duration", "Forced S&L"],
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
capturedFrames.forEach((f) => {
|
|
316
|
+
if (f.scripts.length === 0) {
|
|
317
|
+
rows.push([
|
|
318
|
+
f.startTime.toFixed(2),
|
|
319
|
+
f.duration.toFixed(2),
|
|
320
|
+
f.blockingDuration.toFixed(2),
|
|
321
|
+
"", "", "", "",
|
|
322
|
+
]);
|
|
323
|
+
} else {
|
|
324
|
+
f.scripts.forEach((s) => {
|
|
325
|
+
rows.push([
|
|
326
|
+
f.startTime.toFixed(2),
|
|
327
|
+
f.duration.toFixed(2),
|
|
328
|
+
f.blockingDuration.toFixed(2),
|
|
329
|
+
s.sourceURL,
|
|
330
|
+
s.sourceFunctionName,
|
|
331
|
+
s.duration.toFixed(2),
|
|
332
|
+
s.forcedStyleAndLayoutDuration.toFixed(2),
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n");
|
|
339
|
+
const blob = new Blob([csv], { type: "text/csv" });
|
|
340
|
+
const url = URL.createObjectURL(blob);
|
|
341
|
+
const a = document.createElement("a");
|
|
342
|
+
a.href = url;
|
|
343
|
+
a.download = `loaf-data-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
344
|
+
a.click();
|
|
345
|
+
URL.revokeObjectURL(url);
|
|
346
|
+
|
|
347
|
+
console.log(`%cā
Exported ${capturedFrames.length} frames to CSV`, "color: #22c55e; font-weight: bold;");
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get raw data for custom analysis
|
|
352
|
+
*/
|
|
353
|
+
getRawData() {
|
|
354
|
+
return capturedFrames;
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Clear all captured data
|
|
359
|
+
*/
|
|
360
|
+
clear() {
|
|
361
|
+
capturedFrames.length = 0;
|
|
362
|
+
console.log("%cā
Captured data cleared", "color: #22c55e; font-weight: bold;");
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Show help
|
|
367
|
+
*/
|
|
368
|
+
help() {
|
|
369
|
+
console.group("%cš LoAF Helpers - Commands", "font-weight: bold; font-size: 14px;");
|
|
370
|
+
console.log("");
|
|
371
|
+
console.log("%csummary()%c - Overview of all captured frames", "font-weight: bold; color: #3b82f6;", "");
|
|
372
|
+
console.log("%ctopScripts(n)%c - Top N slowest scripts (default: 10)", "font-weight: bold; color: #3b82f6;", "");
|
|
373
|
+
console.log("%cfilter({ minDuration, maxDuration })%c - Filter frames by duration", "font-weight: bold; color: #3b82f6;", "");
|
|
374
|
+
console.log("%cfindByURL(search)%c - Find frames by script URL", "font-weight: bold; color: #3b82f6;", "");
|
|
375
|
+
console.log("%cpercentiles([50,75,95,99])%c - Calculate percentiles for RUM", "font-weight: bold; color: #3b82f6;", "");
|
|
376
|
+
console.log("%cexportJSON()%c - Download data as JSON", "font-weight: bold; color: #3b82f6;", "");
|
|
377
|
+
console.log("%cexportCSV()%c - Download data as CSV", "font-weight: bold; color: #3b82f6;", "");
|
|
378
|
+
console.log("%cgetRawData()%c - Get raw frame array", "font-weight: bold; color: #3b82f6;", "");
|
|
379
|
+
console.log("%cclear()%c - Clear captured data", "font-weight: bold; color: #3b82f6;", "");
|
|
380
|
+
console.groupEnd();
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Initial message
|
|
385
|
+
console.log("%cš§ LoAF Helpers Loaded", "font-weight: bold; font-size: 14px;");
|
|
386
|
+
console.log(" Observing long animation frames (>50ms)...");
|
|
387
|
+
console.log("");
|
|
388
|
+
console.log(" Quick start: %cloafHelpers.summary()%c", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
|
|
389
|
+
console.log(" All commands: %cloafHelpers.help()%c", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
script: "Long-Animation-Frames-Helpers",
|
|
393
|
+
status: "tracking",
|
|
394
|
+
message: "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc. Call loafHelpers.getRawData() to get raw frame data.",
|
|
395
|
+
getDataFn: "loafHelpers.getRawData",
|
|
396
|
+
};
|
|
397
|
+
})();
|