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.
Files changed (67) hide show
  1. package/README.md +139 -0
  2. package/package.json +54 -0
  3. package/snippets/CoreWebVitals/CLS.js +88 -0
  4. package/snippets/CoreWebVitals/INP.js +432 -0
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
  9. package/snippets/CoreWebVitals/LCP.js +135 -0
  10. package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
  12. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
  15. package/snippets/Interaction/Interactions.js +220 -0
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
  19. package/snippets/Interaction/Long-Animation-Frames.js +298 -0
  20. package/snippets/Interaction/LongTask.js +203 -0
  21. package/snippets/Interaction/Scroll-Performance.js +359 -0
  22. package/snippets/Interaction/WORKFLOWS.md +205 -0
  23. package/snippets/Loading/Back-Forward-Cache.js +473 -0
  24. package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
  25. package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
  26. package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
  27. package/snippets/Loading/Content-Visibility.js +352 -0
  28. package/snippets/Loading/Critical-CSS-Detection.js +329 -0
  29. package/snippets/Loading/Event-Processing-Time.js +179 -0
  30. package/snippets/Loading/FCP.js +101 -0
  31. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
  32. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
  33. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
  34. package/snippets/Loading/Find-render-blocking-resources.js +161 -0
  35. package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
  36. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
  37. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
  38. package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
  39. package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
  40. package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
  41. package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
  42. package/snippets/Loading/Priority-Hints-Audit.js +336 -0
  43. package/snippets/Loading/Resource-Hints-Validation.js +503 -0
  44. package/snippets/Loading/Resource-Hints.js +400 -0
  45. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
  46. package/snippets/Loading/Script-Loading.js +332 -0
  47. package/snippets/Loading/Service-Worker-Analysis.js +192 -0
  48. package/snippets/Loading/TTFB-Resources.js +115 -0
  49. package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
  50. package/snippets/Loading/TTFB.js +37 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
  52. package/snippets/Loading/WORKFLOWS.md +163 -0
  53. package/snippets/Media/Image-Element-Audit.js +350 -0
  54. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
  55. package/snippets/Media/Video-Element-Audit.js +282 -0
  56. package/snippets/Media/WORKFLOWS.md +190 -0
  57. package/snippets/README.md +98 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
  59. package/snippets/Resources/WORKFLOWS.md +173 -0
  60. package/src/bin.js +229 -0
  61. package/src/decision-tree.js +19 -0
  62. package/src/load-snippet.js +23 -0
  63. package/src/reporters/human.js +179 -0
  64. package/src/reporters/json.js +3 -0
  65. package/src/runner.js +61 -0
  66. package/src/workflows/audit.js +21 -0
  67. 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
+ })();