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,286 @@
|
|
|
1
|
+
// Forced Synchronous Layout Detector
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
let isDirty = false;
|
|
6
|
+
let lastMutationTime = 0;
|
|
7
|
+
let rafPending = false;
|
|
8
|
+
const fslEvents = [];
|
|
9
|
+
const restorations = [];
|
|
10
|
+
|
|
11
|
+
const getElementDesc = (el) => {
|
|
12
|
+
let desc = el.tagName.toLowerCase();
|
|
13
|
+
if (el.id) desc += `#${el.id}`;
|
|
14
|
+
if (el.className && typeof el.className === "string" && el.className.trim()) {
|
|
15
|
+
desc += `.${el.className.trim().split(/\s+/).join(".")}`;
|
|
16
|
+
}
|
|
17
|
+
return desc;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const markDirty = () => {
|
|
21
|
+
isDirty = true;
|
|
22
|
+
lastMutationTime = performance.now();
|
|
23
|
+
if (rafPending) return;
|
|
24
|
+
rafPending = true;
|
|
25
|
+
requestAnimationFrame(() => {
|
|
26
|
+
isDirty = false;
|
|
27
|
+
rafPending = false;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ─── SYNCHRONOUS MUTATION INTERCEPTION ──────────────────────────────────────
|
|
32
|
+
// MutationObserver fires asynchronously (microtask after the stack clears),
|
|
33
|
+
// so it cannot set isDirty before a geometric read in the same synchronous
|
|
34
|
+
// block. We intercept the DOM mutation APIs directly instead.
|
|
35
|
+
|
|
36
|
+
// classList: add / remove / toggle / replace
|
|
37
|
+
const domTokenListProto = DOMTokenList.prototype;
|
|
38
|
+
["add", "remove", "toggle", "replace"].forEach((method) => {
|
|
39
|
+
const orig = domTokenListProto[method];
|
|
40
|
+
domTokenListProto[method] = function (...args) {
|
|
41
|
+
markDirty();
|
|
42
|
+
return orig.apply(this, args);
|
|
43
|
+
};
|
|
44
|
+
restorations.push(() => (domTokenListProto[method] = orig));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// element.setAttribute('class', ...) / element.setAttribute('style', ...)
|
|
48
|
+
const origSetAttribute = Element.prototype.setAttribute;
|
|
49
|
+
Element.prototype.setAttribute = function (name, value) {
|
|
50
|
+
if (name === "class" || name === "style") markDirty();
|
|
51
|
+
return origSetAttribute.call(this, name, value);
|
|
52
|
+
};
|
|
53
|
+
restorations.push(
|
|
54
|
+
() => (Element.prototype.setAttribute = origSetAttribute)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// element.style.setProperty(...) and element.style.cssText = ...
|
|
58
|
+
const cssStyleDeclarationProto = CSSStyleDeclaration.prototype;
|
|
59
|
+
const origSetProperty = cssStyleDeclarationProto.setProperty;
|
|
60
|
+
cssStyleDeclarationProto.setProperty = function (...args) {
|
|
61
|
+
markDirty();
|
|
62
|
+
return origSetProperty.apply(this, args);
|
|
63
|
+
};
|
|
64
|
+
restorations.push(
|
|
65
|
+
() => (cssStyleDeclarationProto.setProperty = origSetProperty)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const cssTextDescriptor = Object.getOwnPropertyDescriptor(
|
|
69
|
+
cssStyleDeclarationProto,
|
|
70
|
+
"cssText"
|
|
71
|
+
);
|
|
72
|
+
if (cssTextDescriptor?.set) {
|
|
73
|
+
Object.defineProperty(cssStyleDeclarationProto, "cssText", {
|
|
74
|
+
...cssTextDescriptor,
|
|
75
|
+
set(value) {
|
|
76
|
+
markDirty();
|
|
77
|
+
cssTextDescriptor.set.call(this, value);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
restorations.push(() =>
|
|
81
|
+
Object.defineProperty(cssStyleDeclarationProto, "cssText", cssTextDescriptor)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── GEOMETRIC PROPERTY INTERCEPTION ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const warnFSL = (propName, accessType, el) => {
|
|
88
|
+
if (!isDirty) return;
|
|
89
|
+
const elapsed = (performance.now() - lastMutationTime).toFixed(2);
|
|
90
|
+
const elDesc = el instanceof Element ? getElementDesc(el) : "unknown";
|
|
91
|
+
const stack = new Error().stack;
|
|
92
|
+
|
|
93
|
+
fslEvents.push({
|
|
94
|
+
property: propName,
|
|
95
|
+
accessType,
|
|
96
|
+
element: elDesc,
|
|
97
|
+
sinceLastMutationMs: parseFloat(elapsed),
|
|
98
|
+
stack,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.warn(
|
|
102
|
+
`⚠️ [FSL Detector] Forced Synchronous Layout detected!\n` +
|
|
103
|
+
` Property : ${propName} (${accessType})\n` +
|
|
104
|
+
` Element : ${elDesc}\n` +
|
|
105
|
+
` Since last mutation: ${elapsed} ms\n` +
|
|
106
|
+
` Stack trace:\n${stack}`
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const interceptProp = (proto, propName, accessType) => {
|
|
111
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, propName);
|
|
112
|
+
if (!descriptor) return;
|
|
113
|
+
|
|
114
|
+
const { get: origGet, set: origSet } = descriptor;
|
|
115
|
+
const newDescriptor = { configurable: true, enumerable: descriptor.enumerable };
|
|
116
|
+
|
|
117
|
+
if (origGet) {
|
|
118
|
+
newDescriptor.get = function () {
|
|
119
|
+
if (accessType === "read" || accessType === "readwrite") {
|
|
120
|
+
warnFSL(propName, "read", this);
|
|
121
|
+
}
|
|
122
|
+
return origGet.call(this);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (origSet) {
|
|
127
|
+
newDescriptor.set = function (value) {
|
|
128
|
+
if (accessType === "write" || accessType === "readwrite") {
|
|
129
|
+
warnFSL(propName, "write", this);
|
|
130
|
+
}
|
|
131
|
+
origSet.call(this, value);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Object.defineProperty(proto, propName, newDescriptor);
|
|
136
|
+
restorations.push(() => Object.defineProperty(proto, propName, descriptor));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Element.prototype: client* and scroll*
|
|
140
|
+
const elementReadProps = [
|
|
141
|
+
"clientTop", "clientLeft", "clientWidth", "clientHeight",
|
|
142
|
+
"scrollWidth", "scrollHeight",
|
|
143
|
+
];
|
|
144
|
+
const elementReadWriteProps = ["scrollTop", "scrollLeft"];
|
|
145
|
+
|
|
146
|
+
elementReadProps.forEach((p) => interceptProp(Element.prototype, p, "read"));
|
|
147
|
+
elementReadWriteProps.forEach((p) => interceptProp(Element.prototype, p, "readwrite"));
|
|
148
|
+
|
|
149
|
+
// HTMLElement.prototype: offset*
|
|
150
|
+
const htmlElementReadProps = [
|
|
151
|
+
"offsetTop", "offsetLeft", "offsetWidth", "offsetHeight",
|
|
152
|
+
];
|
|
153
|
+
htmlElementReadProps.forEach((p) => interceptProp(HTMLElement.prototype, p, "read"));
|
|
154
|
+
|
|
155
|
+
// getBoundingClientRect
|
|
156
|
+
const origGetBCR = Element.prototype.getBoundingClientRect;
|
|
157
|
+
const getBCRDescriptor = Object.getOwnPropertyDescriptor(
|
|
158
|
+
Element.prototype,
|
|
159
|
+
"getBoundingClientRect"
|
|
160
|
+
);
|
|
161
|
+
Element.prototype.getBoundingClientRect = function () {
|
|
162
|
+
warnFSL("getBoundingClientRect()", "read", this);
|
|
163
|
+
return origGetBCR.call(this);
|
|
164
|
+
};
|
|
165
|
+
restorations.push(() =>
|
|
166
|
+
Object.defineProperty(Element.prototype, "getBoundingClientRect", getBCRDescriptor)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// ─── SUMMARY ─────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
window.getFSLSummary = () => {
|
|
172
|
+
console.group("%c📊 FSL Detector Summary", "font-weight: bold; font-size: 14px;");
|
|
173
|
+
|
|
174
|
+
if (fslEvents.length === 0) {
|
|
175
|
+
console.log(" No forced synchronous layouts detected.");
|
|
176
|
+
console.log(" ✅ The page is not triggering FSL patterns.");
|
|
177
|
+
console.groupEnd();
|
|
178
|
+
return {
|
|
179
|
+
script: "Forced-Synchronous-Layout",
|
|
180
|
+
status: "ok",
|
|
181
|
+
count: 0,
|
|
182
|
+
details: { fslEvents: [] },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const byProperty = fslEvents.reduce((acc, e) => {
|
|
187
|
+
acc[e.property] = (acc[e.property] || 0) + 1;
|
|
188
|
+
return acc;
|
|
189
|
+
}, {});
|
|
190
|
+
|
|
191
|
+
const byElement = fslEvents.reduce((acc, e) => {
|
|
192
|
+
acc[e.element] = (acc[e.element] || 0) + 1;
|
|
193
|
+
return acc;
|
|
194
|
+
}, {});
|
|
195
|
+
|
|
196
|
+
console.log("");
|
|
197
|
+
console.log("%cStatistics:", "font-weight: bold;");
|
|
198
|
+
console.log(` Total FSL events: ${fslEvents.length}`);
|
|
199
|
+
console.log(
|
|
200
|
+
` Fastest since mutation: ${Math.min(...fslEvents.map((e) => e.sinceLastMutationMs)).toFixed(2)} ms`
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
console.log("");
|
|
204
|
+
console.log("%cBy Property:", "font-weight: bold;");
|
|
205
|
+
console.table(
|
|
206
|
+
Object.entries(byProperty)
|
|
207
|
+
.sort((a, b) => b[1] - a[1])
|
|
208
|
+
.map(([property, count]) => ({ Property: property, Count: count }))
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log("%cBy Element:", "font-weight: bold;");
|
|
213
|
+
console.table(
|
|
214
|
+
Object.entries(byElement)
|
|
215
|
+
.sort((a, b) => b[1] - a[1])
|
|
216
|
+
.map(([element, count]) => ({ Element: element, Count: count }))
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
console.log("");
|
|
220
|
+
console.log("%c💡 Fix:", "font-weight: bold; color: #3b82f6;");
|
|
221
|
+
console.log(" Wrap geometric reads/writes in a double requestAnimationFrame:");
|
|
222
|
+
console.log(" requestAnimationFrame(() => requestAnimationFrame(() => {");
|
|
223
|
+
console.log(" element.scrollTop = 0; // or any layout-triggering access");
|
|
224
|
+
console.log(" }));");
|
|
225
|
+
|
|
226
|
+
console.groupEnd();
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
script: "Forced-Synchronous-Layout",
|
|
230
|
+
status: "ok",
|
|
231
|
+
count: fslEvents.length,
|
|
232
|
+
details: {
|
|
233
|
+
byProperty,
|
|
234
|
+
byElement,
|
|
235
|
+
fastestSinceLastMutationMs: Math.min(
|
|
236
|
+
...fslEvents.map((e) => e.sinceLastMutationMs)
|
|
237
|
+
),
|
|
238
|
+
fslEvents: fslEvents.map(({ property, accessType, element, sinceLastMutationMs }) => ({
|
|
239
|
+
property,
|
|
240
|
+
accessType,
|
|
241
|
+
element,
|
|
242
|
+
sinceLastMutationMs,
|
|
243
|
+
})),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
window.stopFSLDetector = () => {
|
|
249
|
+
restorations.forEach((restore) => restore());
|
|
250
|
+
restorations.length = 0;
|
|
251
|
+
console.log(
|
|
252
|
+
`%c🛑 FSL Detector stopped. Total FSL events detected: ${fslEvents.length}`,
|
|
253
|
+
"font-weight: bold;"
|
|
254
|
+
);
|
|
255
|
+
delete window.getFSLSummary;
|
|
256
|
+
delete window.stopFSLDetector;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
console.log("%c⚡ FSL Detector Active", "font-weight: bold; font-size: 14px;");
|
|
260
|
+
console.log(" Monitoring class/style mutations and geometric property access.");
|
|
261
|
+
console.log(
|
|
262
|
+
" Call %cgetFSLSummary()%c for the report or %cstopFSLDetector()%c to stop.",
|
|
263
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
264
|
+
"",
|
|
265
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
266
|
+
""
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
script: "Forced-Synchronous-Layout",
|
|
271
|
+
status: "tracking",
|
|
272
|
+
count: 0,
|
|
273
|
+
details: {
|
|
274
|
+
interceptedProperties: [
|
|
275
|
+
...elementReadProps,
|
|
276
|
+
...elementReadWriteProps,
|
|
277
|
+
...htmlElementReadProps,
|
|
278
|
+
"getBoundingClientRect()",
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
message:
|
|
282
|
+
"FSL Detector active. Reproduce the interaction then call getFSLSummary() to inspect results.",
|
|
283
|
+
getDataFn: "getFSLSummary",
|
|
284
|
+
stopFn: "stopFSLDetector",
|
|
285
|
+
};
|
|
286
|
+
})();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Input Latency Breakdown
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
|
+
|
|
7
|
+
const valueToRating = (score) =>
|
|
8
|
+
score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";
|
|
9
|
+
|
|
10
|
+
const RATING_COLORS = {
|
|
11
|
+
good: "#0CCE6A",
|
|
12
|
+
"needs-improvement": "#FFA400",
|
|
13
|
+
poor: "#FF4E42",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const RATING_ICONS = {
|
|
17
|
+
good: "🟢",
|
|
18
|
+
"needs-improvement": "🟡",
|
|
19
|
+
poor: "🔴",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Interactions grouped by event type
|
|
23
|
+
const byEventType = {};
|
|
24
|
+
|
|
25
|
+
const observer = new PerformanceObserver((list) => {
|
|
26
|
+
// Group entries by interactionId; keep the longest entry per interaction
|
|
27
|
+
const interactions = {};
|
|
28
|
+
|
|
29
|
+
for (const entry of list.getEntries().filter((e) => e.interactionId)) {
|
|
30
|
+
interactions[entry.interactionId] =
|
|
31
|
+
interactions[entry.interactionId] || [];
|
|
32
|
+
interactions[entry.interactionId].push(entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const group of Object.values(interactions)) {
|
|
36
|
+
const entry = group.reduce((prev, curr) =>
|
|
37
|
+
prev.duration >= curr.duration ? prev : curr
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const eventType = entry.name; // "click", "keydown", "pointerdown", etc.
|
|
41
|
+
const inputDelay = entry.processingStart - entry.startTime;
|
|
42
|
+
const processingTime = entry.processingEnd - entry.processingStart;
|
|
43
|
+
const presentationDelay = Math.max(
|
|
44
|
+
4,
|
|
45
|
+
entry.startTime + entry.duration - entry.processingEnd
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!byEventType[eventType]) {
|
|
49
|
+
byEventType[eventType] = {
|
|
50
|
+
count: 0,
|
|
51
|
+
durations: [],
|
|
52
|
+
inputDelays: [],
|
|
53
|
+
processingTimes: [],
|
|
54
|
+
presentationDelays: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bucket = byEventType[eventType];
|
|
59
|
+
bucket.count++;
|
|
60
|
+
bucket.durations.push(entry.duration);
|
|
61
|
+
bucket.inputDelays.push(inputDelay);
|
|
62
|
+
bucket.processingTimes.push(processingTime);
|
|
63
|
+
bucket.presentationDelays.push(presentationDelay);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
observer.observe({ type: "event", durationThreshold: 0, buffered: true });
|
|
68
|
+
|
|
69
|
+
const p75 = (arr) => {
|
|
70
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
71
|
+
return (
|
|
72
|
+
sorted[Math.floor(sorted.length * 0.75)] ?? sorted[sorted.length - 1]
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
window.getInputLatencyBreakdown = () => {
|
|
77
|
+
const types = Object.keys(byEventType);
|
|
78
|
+
|
|
79
|
+
if (types.length === 0) {
|
|
80
|
+
console.log("%c⌨️ No interactions recorded yet.", "font-weight: bold;");
|
|
81
|
+
console.log(
|
|
82
|
+
" Interact with the page (click, type, etc.) and call this again."
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.group(
|
|
88
|
+
"%c⌨️ Input Latency Breakdown by Event Type",
|
|
89
|
+
"font-weight: bold; font-size: 14px;"
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
for (const eventType of types.sort()) {
|
|
93
|
+
const b = byEventType[eventType];
|
|
94
|
+
|
|
95
|
+
const p75Total = p75(b.durations);
|
|
96
|
+
const p75InputDelay = p75(b.inputDelays);
|
|
97
|
+
const p75Processing = p75(b.processingTimes);
|
|
98
|
+
const p75Presentation = p75(b.presentationDelays);
|
|
99
|
+
const p75Sum = p75InputDelay + p75Processing + p75Presentation;
|
|
100
|
+
|
|
101
|
+
const phases = [
|
|
102
|
+
{ name: "Input Delay", value: p75InputDelay },
|
|
103
|
+
{ name: "Processing", value: p75Processing },
|
|
104
|
+
{ name: "Presentation", value: p75Presentation },
|
|
105
|
+
];
|
|
106
|
+
const bottleneck = phases.reduce((a, b) => (a.value > b.value ? a : b));
|
|
107
|
+
|
|
108
|
+
const rating = valueToRating(p75Total);
|
|
109
|
+
const icon = RATING_ICONS[rating];
|
|
110
|
+
const color = RATING_COLORS[rating];
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
`%c${icon} ${eventType}%c (${b.count} interaction${b.count > 1 ? "s" : ""}) P75: ${formatMs(p75Total)} Input Delay: ${formatMs(p75InputDelay)} Processing: ${formatMs(p75Processing)} Presentation: ${formatMs(p75Presentation)}`,
|
|
114
|
+
`font-weight: bold; color: ${color};`,
|
|
115
|
+
"color: inherit;"
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Visual distribution bar based on P75 phase values
|
|
119
|
+
const barWidth = 36;
|
|
120
|
+
const inputBar = "█".repeat(
|
|
121
|
+
Math.max(1, Math.round((p75InputDelay / p75Sum) * barWidth))
|
|
122
|
+
);
|
|
123
|
+
const procBar = "▓".repeat(
|
|
124
|
+
Math.max(1, Math.round((p75Processing / p75Sum) * barWidth))
|
|
125
|
+
);
|
|
126
|
+
const presBar = "░".repeat(
|
|
127
|
+
Math.max(1, Math.round((p75Presentation / p75Sum) * barWidth))
|
|
128
|
+
);
|
|
129
|
+
console.log(` ${inputBar}${procBar}${presBar}`);
|
|
130
|
+
console.log(
|
|
131
|
+
` █ Input Delay (${((p75InputDelay / p75Sum) * 100).toFixed(0)}%) ` +
|
|
132
|
+
`▓ Processing (${((p75Processing / p75Sum) * 100).toFixed(0)}%) ` +
|
|
133
|
+
`░ Presentation (${((p75Presentation / p75Sum) * 100).toFixed(0)}%)`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (rating !== "good") {
|
|
137
|
+
console.log(
|
|
138
|
+
` ⚠️ Bottleneck: ${bottleneck.name} — `,
|
|
139
|
+
bottleneck.name === "Input Delay"
|
|
140
|
+
? "break up long tasks blocking the main thread (scheduler.yield(), setTimeout)"
|
|
141
|
+
: bottleneck.name === "Processing"
|
|
142
|
+
? "optimize event handlers or consider debouncing"
|
|
143
|
+
: "reduce DOM changes or avoid layout thrashing after the handler"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Highlight the event type with the highest P75
|
|
151
|
+
const worstType = types.reduce((a, b) =>
|
|
152
|
+
p75(byEventType[a].durations) >= p75(byEventType[b].durations) ? a : b
|
|
153
|
+
);
|
|
154
|
+
const worstP75 = p75(byEventType[worstType].durations);
|
|
155
|
+
|
|
156
|
+
if (valueToRating(worstP75) !== "good") {
|
|
157
|
+
console.log(
|
|
158
|
+
`%c🎯 Highest latency: ${worstType} (P75: ${formatMs(worstP75)})`,
|
|
159
|
+
"font-weight: bold; color: #ef4444;"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const typeSummary = {};
|
|
164
|
+
for (const [type, b] of Object.entries(byEventType)) {
|
|
165
|
+
typeSummary[type] = {
|
|
166
|
+
count: b.count,
|
|
167
|
+
p75Ms: Math.round(p75(b.durations)),
|
|
168
|
+
inputDelayMs: Math.round(p75(b.inputDelays)),
|
|
169
|
+
processingMs: Math.round(p75(b.processingTimes)),
|
|
170
|
+
presentationMs: Math.round(p75(b.presentationDelays)),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
console.groupEnd();
|
|
174
|
+
return {
|
|
175
|
+
script: "Input-Latency-Breakdown",
|
|
176
|
+
status: "ok",
|
|
177
|
+
count: types.length,
|
|
178
|
+
details: { eventTypes: typeSummary },
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
console.log(
|
|
183
|
+
"%c⌨️ Input Latency Breakdown Active",
|
|
184
|
+
"font-weight: bold; font-size: 14px;"
|
|
185
|
+
);
|
|
186
|
+
console.log(" Interact with the page (click, type, etc.).");
|
|
187
|
+
console.log(
|
|
188
|
+
" Call %cgetInputLatencyBreakdown()%c for the aggregated report.",
|
|
189
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
190
|
+
""
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
script: "Input-Latency-Breakdown",
|
|
195
|
+
status: "tracking",
|
|
196
|
+
message: "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
|
|
197
|
+
getDataFn: "getInputLatencyBreakdown",
|
|
198
|
+
};
|
|
199
|
+
})();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Interaction Tracking
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
|
+
|
|
7
|
+
// INP thresholds
|
|
8
|
+
const valueToRating = (score) =>
|
|
9
|
+
score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";
|
|
10
|
+
|
|
11
|
+
const RATING_COLORS = {
|
|
12
|
+
good: "#0CCE6A",
|
|
13
|
+
"needs-improvement": "#FFA400",
|
|
14
|
+
poor: "#FF4E42",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const RATING_ICONS = {
|
|
18
|
+
good: "🟢",
|
|
19
|
+
"needs-improvement": "🟡",
|
|
20
|
+
poor: "🔴",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Track all interactions for summary
|
|
24
|
+
const allInteractions = [];
|
|
25
|
+
|
|
26
|
+
const observer = new PerformanceObserver((list) => {
|
|
27
|
+
const interactions = {};
|
|
28
|
+
|
|
29
|
+
for (const entry of list
|
|
30
|
+
.getEntries()
|
|
31
|
+
.filter((entry) => entry.interactionId)) {
|
|
32
|
+
interactions[entry.interactionId] = interactions[entry.interactionId] || [];
|
|
33
|
+
interactions[entry.interactionId].push(entry);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const interaction of Object.values(interactions)) {
|
|
37
|
+
const entry = interaction.reduce((prev, curr) =>
|
|
38
|
+
prev.duration >= curr.duration ? prev : curr
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const value = entry.duration;
|
|
42
|
+
const rating = valueToRating(value);
|
|
43
|
+
const icon = RATING_ICONS[rating];
|
|
44
|
+
const color = RATING_COLORS[rating];
|
|
45
|
+
|
|
46
|
+
// Store for summary
|
|
47
|
+
allInteractions.push({
|
|
48
|
+
duration: value,
|
|
49
|
+
rating,
|
|
50
|
+
target: entry.target,
|
|
51
|
+
type: entry.name,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Calculate sub-parts
|
|
55
|
+
const inputDelay = entry.processingStart - entry.startTime;
|
|
56
|
+
const processingTime = entry.processingEnd - entry.processingStart;
|
|
57
|
+
const presentationDelay = Math.max(
|
|
58
|
+
4,
|
|
59
|
+
entry.startTime + entry.duration - entry.processingEnd
|
|
60
|
+
);
|
|
61
|
+
const total = inputDelay + processingTime + presentationDelay;
|
|
62
|
+
|
|
63
|
+
// Find longest sub-part
|
|
64
|
+
const subParts = [
|
|
65
|
+
{ name: "Input Delay", value: inputDelay },
|
|
66
|
+
{ name: "Processing Time", value: processingTime },
|
|
67
|
+
{ name: "Presentation Delay", value: presentationDelay },
|
|
68
|
+
];
|
|
69
|
+
const longest = subParts.reduce((a, b) => (a.value > b.value ? a : b));
|
|
70
|
+
|
|
71
|
+
console.groupCollapsed(
|
|
72
|
+
`%c${icon} Interaction: ${formatMs(value)} (${rating})`,
|
|
73
|
+
`font-weight: bold; color: ${color};`
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Target info
|
|
77
|
+
console.log("%cTarget:", "font-weight: bold;", entry.target);
|
|
78
|
+
console.log(` Event type: ${entry.name}`);
|
|
79
|
+
|
|
80
|
+
// Sub-parts breakdown
|
|
81
|
+
console.log("");
|
|
82
|
+
console.log("%cSub-parts breakdown:", "font-weight: bold;");
|
|
83
|
+
|
|
84
|
+
const tableData = subParts.map((part) => {
|
|
85
|
+
const percent = ((part.value / total) * 100).toFixed(0);
|
|
86
|
+
const isLongest = part.name === longest.name;
|
|
87
|
+
return {
|
|
88
|
+
"Sub-part": isLongest ? `⚠️ ${part.name}` : part.name,
|
|
89
|
+
Duration: formatMs(part.value),
|
|
90
|
+
"%": `${percent}%`,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.table(tableData);
|
|
95
|
+
|
|
96
|
+
// Visual bar
|
|
97
|
+
const barWidth = 40;
|
|
98
|
+
const inputBar = "█".repeat(Math.round((inputDelay / total) * barWidth));
|
|
99
|
+
const procBar = "▓".repeat(Math.round((processingTime / total) * barWidth));
|
|
100
|
+
const presBar = "░".repeat(Math.round((presentationDelay / total) * barWidth));
|
|
101
|
+
console.log(` ${inputBar}${procBar}${presBar}`);
|
|
102
|
+
console.log(" █ Input ▓ Processing ░ Presentation");
|
|
103
|
+
|
|
104
|
+
// Recommendation if slow
|
|
105
|
+
if (rating !== "good") {
|
|
106
|
+
console.log("");
|
|
107
|
+
console.log("%c💡 Optimization hint:", "font-weight: bold; color: #3b82f6;");
|
|
108
|
+
if (longest.name === "Input Delay") {
|
|
109
|
+
console.log(" Break up long tasks blocking the main thread");
|
|
110
|
+
console.log(" Use requestIdleCallback or setTimeout for non-critical work");
|
|
111
|
+
} else if (longest.name === "Processing Time") {
|
|
112
|
+
console.log(" Optimize event handlers, reduce JavaScript complexity");
|
|
113
|
+
console.log(" Consider debouncing or using web workers");
|
|
114
|
+
} else {
|
|
115
|
+
console.log(" Reduce DOM size or complexity of updates");
|
|
116
|
+
console.log(" Avoid forced synchronous layouts");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.groupEnd();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
observer.observe({
|
|
125
|
+
type: "event",
|
|
126
|
+
durationThreshold: 0,
|
|
127
|
+
buffered: true,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Summary function
|
|
131
|
+
window.getInteractionSummary = () => {
|
|
132
|
+
if (allInteractions.length === 0) {
|
|
133
|
+
console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
|
|
134
|
+
console.log(" Interact with the page (click, type, etc.) and call this again.");
|
|
135
|
+
return { script: "Interactions", status: "error", error: "No interactions recorded yet", count: 0 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.group("%c📊 Interaction Summary", "font-weight: bold; font-size: 14px;");
|
|
139
|
+
|
|
140
|
+
const durations = allInteractions.map((i) => i.duration);
|
|
141
|
+
const worst = Math.max(...durations);
|
|
142
|
+
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
|
143
|
+
const p75 = durations.sort((a, b) => a - b)[Math.floor(durations.length * 0.75)];
|
|
144
|
+
|
|
145
|
+
const worstRating = valueToRating(worst);
|
|
146
|
+
const p75Rating = valueToRating(p75);
|
|
147
|
+
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log("%cStatistics:", "font-weight: bold;");
|
|
150
|
+
console.log(` Total interactions: ${allInteractions.length}`);
|
|
151
|
+
console.log(
|
|
152
|
+
` Worst: %c${formatMs(worst)} (${worstRating})`,
|
|
153
|
+
`color: ${RATING_COLORS[worstRating]};`
|
|
154
|
+
);
|
|
155
|
+
console.log(
|
|
156
|
+
` P75 (INP): %c${formatMs(p75)} (${p75Rating})`,
|
|
157
|
+
`color: ${RATING_COLORS[p75Rating]};`
|
|
158
|
+
);
|
|
159
|
+
console.log(` Average: ${formatMs(avg)}`);
|
|
160
|
+
|
|
161
|
+
// Rating breakdown
|
|
162
|
+
const good = allInteractions.filter((i) => i.rating === "good").length;
|
|
163
|
+
const needsImprovement = allInteractions.filter(
|
|
164
|
+
(i) => i.rating === "needs-improvement"
|
|
165
|
+
).length;
|
|
166
|
+
const poor = allInteractions.filter((i) => i.rating === "poor").length;
|
|
167
|
+
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log("%cBy rating:", "font-weight: bold;");
|
|
170
|
+
console.log(` 🟢 Good (≤200ms): ${good}`);
|
|
171
|
+
console.log(` 🟡 Needs Improvement (≤500ms): ${needsImprovement}`);
|
|
172
|
+
console.log(` 🔴 Poor (>500ms): ${poor}`);
|
|
173
|
+
|
|
174
|
+
// Slow interactions
|
|
175
|
+
const slowInteractions = allInteractions.filter((i) => i.rating !== "good");
|
|
176
|
+
if (slowInteractions.length > 0) {
|
|
177
|
+
console.log("");
|
|
178
|
+
console.log("%c⚠️ Slow interactions:", "font-weight: bold; color: #ef4444;");
|
|
179
|
+
slowInteractions.forEach((i, idx) => {
|
|
180
|
+
const icon = RATING_ICONS[i.rating];
|
|
181
|
+
console.log(` ${idx + 1}. ${icon} ${i.type} - ${formatMs(i.duration)}`, i.target);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byRating = {
|
|
186
|
+
good: allInteractions.filter((i) => i.rating === "good").length,
|
|
187
|
+
"needs-improvement": allInteractions.filter((i) => i.rating === "needs-improvement").length,
|
|
188
|
+
poor: allInteractions.filter((i) => i.rating === "poor").length,
|
|
189
|
+
};
|
|
190
|
+
console.groupEnd();
|
|
191
|
+
return {
|
|
192
|
+
script: "Interactions",
|
|
193
|
+
status: "ok",
|
|
194
|
+
count: allInteractions.length,
|
|
195
|
+
details: {
|
|
196
|
+
totalInteractions: allInteractions.length,
|
|
197
|
+
worstMs: Math.round(worst),
|
|
198
|
+
avgMs: Math.round(avg),
|
|
199
|
+
p75Ms: Math.round(p75),
|
|
200
|
+
byRating,
|
|
201
|
+
},
|
|
202
|
+
items: allInteractions.map((i) => ({
|
|
203
|
+
type: i.type,
|
|
204
|
+
durationMs: Math.round(i.duration),
|
|
205
|
+
rating: i.rating,
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
console.log("%c👆 Interaction Tracking Active", "font-weight: bold; font-size: 14px;");
|
|
211
|
+
console.log(" Interact with the page to see interaction details.");
|
|
212
|
+
console.log(" Call %cgetInteractionSummary()%c for a summary.", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
script: "Interactions",
|
|
216
|
+
status: "tracking",
|
|
217
|
+
message: "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
|
|
218
|
+
getDataFn: "getInteractionSummary",
|
|
219
|
+
};
|
|
220
|
+
})();
|