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,432 @@
|
|
|
1
|
+
// INP (Interaction to Next Paint) Tracking
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const interactions = [];
|
|
6
|
+
let inpValue = 0;
|
|
7
|
+
let inpEntry = null;
|
|
8
|
+
|
|
9
|
+
const valueToRating = (ms) =>
|
|
10
|
+
ms <= 200 ? "good" : ms <= 500 ? "needs-improvement" : "poor";
|
|
11
|
+
|
|
12
|
+
const RATING = {
|
|
13
|
+
good: { icon: "š¢", color: "#0CCE6A" },
|
|
14
|
+
"needs-improvement": { icon: "š”", color: "#FFA400" },
|
|
15
|
+
poor: { icon: "š“", color: "#FF4E42" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
19
|
+
|
|
20
|
+
// Calculate INP (98th percentile of all interactions)
|
|
21
|
+
const calculateINP = () => {
|
|
22
|
+
if (interactions.length === 0) return { value: 0, entry: null };
|
|
23
|
+
|
|
24
|
+
// Sort by duration
|
|
25
|
+
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
|
|
26
|
+
|
|
27
|
+
// Get 98th percentile (or worst if < 50 interactions)
|
|
28
|
+
const index = interactions.length < 50
|
|
29
|
+
? 0
|
|
30
|
+
: Math.floor(interactions.length * 0.02);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
value: sorted[index].duration,
|
|
34
|
+
entry: sorted[index],
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Format interaction name
|
|
39
|
+
const getInteractionName = (entry) => {
|
|
40
|
+
const target = entry.target;
|
|
41
|
+
if (!target) return entry.name;
|
|
42
|
+
|
|
43
|
+
let selector = target.tagName.toLowerCase();
|
|
44
|
+
if (target.id) selector += `#${target.id}`;
|
|
45
|
+
else if (target.className && typeof target.className === "string") {
|
|
46
|
+
const classes = target.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
47
|
+
if (classes) selector += `.${classes}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `${entry.name} ā ${selector}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Get phase breakdown (requires LoAF support)
|
|
54
|
+
const getPhaseBreakdown = (entry) => {
|
|
55
|
+
const phases = {
|
|
56
|
+
inputDelay: 0,
|
|
57
|
+
processingTime: 0,
|
|
58
|
+
presentationDelay: 0,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (entry.processingStart && entry.processingEnd) {
|
|
62
|
+
phases.inputDelay = entry.processingStart - entry.startTime;
|
|
63
|
+
phases.processingTime = entry.processingEnd - entry.processingStart;
|
|
64
|
+
phases.presentationDelay = entry.duration - phases.inputDelay - phases.processingTime;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return phases;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Observer for interactions
|
|
71
|
+
const observer = new PerformanceObserver((list) => {
|
|
72
|
+
for (const entry of list.getEntries()) {
|
|
73
|
+
// Only track interactions with interactionId (meaningful interactions)
|
|
74
|
+
if (!entry.interactionId) continue;
|
|
75
|
+
|
|
76
|
+
// Avoid duplicate entries for the same interaction
|
|
77
|
+
const existing = interactions.find(
|
|
78
|
+
(i) => i.interactionId === entry.interactionId
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!existing || entry.duration > existing.duration) {
|
|
82
|
+
// Remove old entry if exists
|
|
83
|
+
if (existing) {
|
|
84
|
+
const idx = interactions.indexOf(existing);
|
|
85
|
+
interactions.splice(idx, 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interactions.push({
|
|
89
|
+
name: entry.name,
|
|
90
|
+
duration: entry.duration,
|
|
91
|
+
startTime: entry.startTime,
|
|
92
|
+
interactionId: entry.interactionId,
|
|
93
|
+
target: entry.target,
|
|
94
|
+
processingStart: entry.processingStart,
|
|
95
|
+
processingEnd: entry.processingEnd,
|
|
96
|
+
formattedName: getInteractionName(entry),
|
|
97
|
+
phases: getPhaseBreakdown(entry),
|
|
98
|
+
entry,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Recalculate INP
|
|
103
|
+
const result = calculateINP();
|
|
104
|
+
inpValue = result.value;
|
|
105
|
+
inpEntry = result.entry;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Observe event timing
|
|
110
|
+
observer.observe({
|
|
111
|
+
type: "event",
|
|
112
|
+
buffered: true,
|
|
113
|
+
durationThreshold: 16, // Only interactions > 16ms (1 frame)
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Log INP summary
|
|
117
|
+
const logINP = () => {
|
|
118
|
+
const rating = valueToRating(inpValue);
|
|
119
|
+
const { icon, color } = RATING[rating];
|
|
120
|
+
|
|
121
|
+
console.group(
|
|
122
|
+
`%cINP: ${icon} ${formatMs(inpValue)} (${rating})`,
|
|
123
|
+
`color: ${color}; font-weight: bold; font-size: 14px;`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
console.log("");
|
|
127
|
+
console.log(`%cš Analysis:`, "font-weight: bold;");
|
|
128
|
+
console.log(` Total interactions tracked: ${interactions.length}`);
|
|
129
|
+
console.log(` INP (98th percentile): ${formatMs(inpValue)}`);
|
|
130
|
+
|
|
131
|
+
if (inpEntry) {
|
|
132
|
+
console.log("");
|
|
133
|
+
console.log(`%cšÆ Worst Interaction (INP):`, "font-weight: bold; color: ${color};");
|
|
134
|
+
console.log(` Event: ${inpEntry.formattedName}`);
|
|
135
|
+
console.log(` Duration: ${formatMs(inpEntry.duration)}`);
|
|
136
|
+
|
|
137
|
+
// Element attribution
|
|
138
|
+
if (inpEntry.target) {
|
|
139
|
+
console.log(` Target Element:`, inpEntry.target);
|
|
140
|
+
|
|
141
|
+
// Get element path for better context
|
|
142
|
+
const getElementPath = (el) => {
|
|
143
|
+
if (!el) return "";
|
|
144
|
+
const parts = [];
|
|
145
|
+
let current = el;
|
|
146
|
+
while (current && current !== document.body && parts.length < 5) {
|
|
147
|
+
let selector = current.tagName.toLowerCase();
|
|
148
|
+
if (current.id) selector += `#${current.id}`;
|
|
149
|
+
else if (current.className && typeof current.className === "string") {
|
|
150
|
+
const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
151
|
+
if (classes) selector += `.${classes}`;
|
|
152
|
+
}
|
|
153
|
+
parts.unshift(selector);
|
|
154
|
+
current = current.parentElement;
|
|
155
|
+
}
|
|
156
|
+
return parts.join(" > ");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const path = getElementPath(inpEntry.target);
|
|
160
|
+
if (path) {
|
|
161
|
+
console.log(` Element Path: ${path}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Phase breakdown
|
|
166
|
+
const phases = inpEntry.phases;
|
|
167
|
+
if (phases.inputDelay > 0) {
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(`%cā±ļø Phase Breakdown:`, "font-weight: bold;");
|
|
170
|
+
console.log(` Input Delay: ${formatMs(phases.inputDelay)}`);
|
|
171
|
+
console.log(` Processing Time: ${formatMs(phases.processingTime)}`);
|
|
172
|
+
console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`);
|
|
173
|
+
|
|
174
|
+
// Visual bar
|
|
175
|
+
const total = inpEntry.duration;
|
|
176
|
+
const barWidth = 40;
|
|
177
|
+
const inputBar = "ā".repeat(Math.round((phases.inputDelay / total) * barWidth));
|
|
178
|
+
const processBar = "ā".repeat(Math.round((phases.processingTime / total) * barWidth));
|
|
179
|
+
const presentBar = "ā".repeat(Math.round((phases.presentationDelay / total) * barWidth));
|
|
180
|
+
console.log(` ${inputBar}${processBar}${presentBar}`);
|
|
181
|
+
console.log(" ā Input ā Processing ā Presentation");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Recommendations based on phases
|
|
185
|
+
if (inpValue > 200 && phases.inputDelay > 0) {
|
|
186
|
+
console.log("");
|
|
187
|
+
console.log("%cš” Recommendations:", "color: #3b82f6; font-weight: bold;");
|
|
188
|
+
|
|
189
|
+
if (phases.inputDelay > 100) {
|
|
190
|
+
console.log(" ⢠High input delay - Break up long tasks before interaction");
|
|
191
|
+
}
|
|
192
|
+
if (phases.processingTime > 200) {
|
|
193
|
+
console.log(" ⢠Long processing time - Optimize event handlers");
|
|
194
|
+
console.log(" ⢠Consider debouncing, use requestIdleCallback for non-critical work");
|
|
195
|
+
}
|
|
196
|
+
if (phases.presentationDelay > 100) {
|
|
197
|
+
console.log(" ⢠High presentation delay - Reduce render complexity");
|
|
198
|
+
console.log(" ⢠Batch DOM updates, use content-visibility");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Slow interactions breakdown
|
|
204
|
+
const slowInteractions = interactions
|
|
205
|
+
.filter((i) => i.duration > 200)
|
|
206
|
+
.sort((a, b) => b.duration - a.duration)
|
|
207
|
+
.slice(0, 10);
|
|
208
|
+
|
|
209
|
+
if (slowInteractions.length > 0) {
|
|
210
|
+
console.log("");
|
|
211
|
+
console.log(`%cš Slow Interactions (> 200ms):`, "color: #ef4444; font-weight: bold;");
|
|
212
|
+
console.table(
|
|
213
|
+
slowInteractions.map((i) => ({
|
|
214
|
+
Event: i.formattedName,
|
|
215
|
+
"Duration (ms)": Math.round(i.duration),
|
|
216
|
+
"Start Time (ms)": Math.round(i.startTime),
|
|
217
|
+
}))
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Show element attribution for top 3
|
|
221
|
+
console.log("");
|
|
222
|
+
console.log(`%cšÆ Element Attribution (top 3):`, "font-weight: bold;");
|
|
223
|
+
slowInteractions.slice(0, 3).forEach((interaction, idx) => {
|
|
224
|
+
console.log(` ${idx + 1}. ${interaction.formattedName} (${Math.round(interaction.duration)}ms)`);
|
|
225
|
+
if (interaction.target) {
|
|
226
|
+
console.log(` Element:`, interaction.target);
|
|
227
|
+
} else {
|
|
228
|
+
console.log(` Element: (no target available)`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Interaction types breakdown
|
|
234
|
+
const byType = {};
|
|
235
|
+
interactions.forEach((i) => {
|
|
236
|
+
const type = i.name;
|
|
237
|
+
if (!byType[type]) {
|
|
238
|
+
byType[type] = { count: 0, totalDuration: 0, maxDuration: 0 };
|
|
239
|
+
}
|
|
240
|
+
byType[type].count++;
|
|
241
|
+
byType[type].totalDuration += i.duration;
|
|
242
|
+
byType[type].maxDuration = Math.max(byType[type].maxDuration, i.duration);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (Object.keys(byType).length > 0) {
|
|
246
|
+
console.log("");
|
|
247
|
+
console.log(`%cš By Interaction Type:`, "font-weight: bold;");
|
|
248
|
+
console.table(
|
|
249
|
+
Object.entries(byType).map(([type, stats]) => ({
|
|
250
|
+
Type: type,
|
|
251
|
+
Count: stats.count,
|
|
252
|
+
"Avg (ms)": Math.round(stats.totalDuration / stats.count),
|
|
253
|
+
"Max (ms)": Math.round(stats.maxDuration),
|
|
254
|
+
}))
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// General recommendations if no phases available
|
|
259
|
+
if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) {
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log("%cš” Recommendations:", "color: #3b82f6; font-weight: bold;");
|
|
262
|
+
console.log(" ⢠Break up long tasks using scheduler.yield() or setTimeout");
|
|
263
|
+
console.log(" ⢠Optimize event handlers - reduce computation time");
|
|
264
|
+
console.log(" ⢠Consider debouncing for frequent events");
|
|
265
|
+
console.log(" ⢠Move heavy work to Web Workers");
|
|
266
|
+
console.log(" ⢠Use requestIdleCallback for non-critical work");
|
|
267
|
+
|
|
268
|
+
console.log("");
|
|
269
|
+
console.log(" Run getINPDetails() for full interaction list");
|
|
270
|
+
console.log(" Use Long Animation Frames snippet to identify blocking scripts");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.groupEnd();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Expose function to check INP anytime
|
|
277
|
+
window.getINP = () => {
|
|
278
|
+
const result = calculateINP();
|
|
279
|
+
inpValue = result.value;
|
|
280
|
+
inpEntry = result.entry;
|
|
281
|
+
logINP();
|
|
282
|
+
const rating = valueToRating(inpValue);
|
|
283
|
+
const details = { totalInteractions: interactions.length };
|
|
284
|
+
if (inpEntry) {
|
|
285
|
+
details.worstEvent = inpEntry.formattedName;
|
|
286
|
+
details.phases = {
|
|
287
|
+
inputDelay: Math.round(inpEntry.phases.inputDelay),
|
|
288
|
+
processingTime: Math.round(inpEntry.phases.processingTime),
|
|
289
|
+
presentationDelay: Math.round(inpEntry.phases.presentationDelay),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (interactions.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
script: "INP",
|
|
295
|
+
status: "error",
|
|
296
|
+
error: "No interactions recorded yet. Interact with the page and call getINP() again.",
|
|
297
|
+
getDataFn: "getINP",
|
|
298
|
+
details,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
script: "INP",
|
|
303
|
+
status: "ok",
|
|
304
|
+
metric: "INP",
|
|
305
|
+
value: Math.round(inpValue),
|
|
306
|
+
unit: "ms",
|
|
307
|
+
rating,
|
|
308
|
+
thresholds: { good: 200, needsImprovement: 500 },
|
|
309
|
+
details,
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Expose function to get all interactions
|
|
314
|
+
window.getINPDetails = () => {
|
|
315
|
+
console.group("%cš All Interactions Detail", "font-weight: bold; font-size: 14px;");
|
|
316
|
+
|
|
317
|
+
if (interactions.length === 0) {
|
|
318
|
+
console.log(" No interactions recorded yet.");
|
|
319
|
+
console.groupEnd();
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
|
|
324
|
+
|
|
325
|
+
console.log("");
|
|
326
|
+
console.log("%cInteraction Summary:", "font-weight: bold;");
|
|
327
|
+
console.table(
|
|
328
|
+
sorted.map((i, idx) => ({
|
|
329
|
+
"#": idx + 1,
|
|
330
|
+
Event: i.formattedName,
|
|
331
|
+
"Duration (ms)": Math.round(i.duration),
|
|
332
|
+
"Start (ms)": Math.round(i.startTime),
|
|
333
|
+
"Input Delay": Math.round(i.phases.inputDelay),
|
|
334
|
+
Processing: Math.round(i.phases.processingTime),
|
|
335
|
+
Presentation: Math.round(i.phases.presentationDelay),
|
|
336
|
+
}))
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Show element attribution for all interactions
|
|
340
|
+
console.log("");
|
|
341
|
+
console.log("%cšÆ Element Attribution:", "font-weight: bold;");
|
|
342
|
+
|
|
343
|
+
const maxToShow = Math.min(sorted.length, 15); // Show up to 15
|
|
344
|
+
sorted.slice(0, maxToShow).forEach((interaction, idx) => {
|
|
345
|
+
const phases = interaction.phases;
|
|
346
|
+
const hasPhases = phases.inputDelay > 0;
|
|
347
|
+
|
|
348
|
+
console.group(
|
|
349
|
+
`${idx + 1}. ${interaction.formattedName} - ${Math.round(interaction.duration)}ms`
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (interaction.target) {
|
|
353
|
+
console.log("Element:", interaction.target);
|
|
354
|
+
|
|
355
|
+
// Get element path for better identification
|
|
356
|
+
const getPath = (el) => {
|
|
357
|
+
if (!el) return "";
|
|
358
|
+
const parts = [];
|
|
359
|
+
let current = el;
|
|
360
|
+
while (current && current !== document.body && parts.length < 5) {
|
|
361
|
+
let selector = current.tagName.toLowerCase();
|
|
362
|
+
if (current.id) selector += `#${current.id}`;
|
|
363
|
+
else if (current.className && typeof current.className === "string") {
|
|
364
|
+
const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
365
|
+
if (classes) selector += `.${classes}`;
|
|
366
|
+
}
|
|
367
|
+
parts.unshift(selector);
|
|
368
|
+
current = current.parentElement;
|
|
369
|
+
}
|
|
370
|
+
return parts.join(" > ");
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const path = getPath(interaction.target);
|
|
374
|
+
if (path) {
|
|
375
|
+
console.log("Path:", path);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
console.log("Element: (no target available)");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (hasPhases) {
|
|
382
|
+
console.log(
|
|
383
|
+
`Phases: Input ${Math.round(phases.inputDelay)}ms | ` +
|
|
384
|
+
`Processing ${Math.round(phases.processingTime)}ms | ` +
|
|
385
|
+
`Presentation ${Math.round(phases.presentationDelay)}ms`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.groupEnd();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (sorted.length > maxToShow) {
|
|
393
|
+
console.log(` ... and ${sorted.length - maxToShow} more interactions`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.groupEnd();
|
|
397
|
+
return sorted;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Log on page hide (final INP)
|
|
401
|
+
document.addEventListener("visibilitychange", () => {
|
|
402
|
+
if (document.visibilityState === "hidden") {
|
|
403
|
+
observer.takeRecords();
|
|
404
|
+
const result = calculateINP();
|
|
405
|
+
inpValue = result.value;
|
|
406
|
+
inpEntry = result.entry;
|
|
407
|
+
|
|
408
|
+
console.log("%cš Final INP (on page hide):", "font-weight: bold;");
|
|
409
|
+
logINP();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
console.log("%cā” INP Tracking Active", "font-weight: bold; font-size: 14px;");
|
|
414
|
+
console.log(" Interactions with duration > 16ms will be tracked.");
|
|
415
|
+
console.log(
|
|
416
|
+
" Call %cgetINP()%c to see current INP value.",
|
|
417
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
418
|
+
""
|
|
419
|
+
);
|
|
420
|
+
console.log(
|
|
421
|
+
" Call %cgetINPDetails()%c for full interaction list.",
|
|
422
|
+
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
423
|
+
""
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
script: "INP",
|
|
428
|
+
status: "tracking",
|
|
429
|
+
message: "INP tracking active. Interact with the page then call getINP() for results.",
|
|
430
|
+
getDataFn: "getINP",
|
|
431
|
+
};
|
|
432
|
+
})();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// LCP Image Entropy Check
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const formatBytes = (bytes) => {
|
|
6
|
+
if (!bytes) return "-";
|
|
7
|
+
const k = 1024;
|
|
8
|
+
const sizes = ["B", "KB", "MB"];
|
|
9
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
10
|
+
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
|
|
14
|
+
|
|
15
|
+
// Get current LCP element
|
|
16
|
+
let lcpElement = null;
|
|
17
|
+
let lcpUrl = null;
|
|
18
|
+
|
|
19
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
20
|
+
const entries = list.getEntries();
|
|
21
|
+
const lastEntry = entries[entries.length - 1];
|
|
22
|
+
if (lastEntry) {
|
|
23
|
+
lcpElement = lastEntry.element;
|
|
24
|
+
lcpUrl = lastEntry.url;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
29
|
+
|
|
30
|
+
// Wait a tick to ensure LCP is captured (for human console output)
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
lcpObserver.disconnect();
|
|
33
|
+
|
|
34
|
+
// Get all images
|
|
35
|
+
const images = [...document.images]
|
|
36
|
+
.filter((img) => {
|
|
37
|
+
const src = img.currentSrc || img.src;
|
|
38
|
+
return src && !src.startsWith("data:image");
|
|
39
|
+
})
|
|
40
|
+
.map((img) => {
|
|
41
|
+
const src = img.currentSrc || img.src;
|
|
42
|
+
const resource = performance.getEntriesByName(src)[0];
|
|
43
|
+
const fileSize = resource?.encodedBodySize || 0;
|
|
44
|
+
const pixels = img.naturalWidth * img.naturalHeight;
|
|
45
|
+
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
|
|
46
|
+
|
|
47
|
+
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
|
|
48
|
+
const isLCP = lcpElement === img || lcpUrl === src;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
element: img,
|
|
52
|
+
src,
|
|
53
|
+
shortSrc: src.split("/").pop()?.split("?")[0] || src,
|
|
54
|
+
width: img.naturalWidth,
|
|
55
|
+
height: img.naturalHeight,
|
|
56
|
+
fileSize,
|
|
57
|
+
bpp,
|
|
58
|
+
isLowEntropy,
|
|
59
|
+
isLCP,
|
|
60
|
+
lcpEligible: !isLowEntropy && bpp > 0,
|
|
61
|
+
};
|
|
62
|
+
})
|
|
63
|
+
.filter((img) => img.bpp > 0); // Only images with measurable BPP
|
|
64
|
+
|
|
65
|
+
console.group("%cš¼ļø Image Entropy Analysis", "font-weight: bold; font-size: 14px;");
|
|
66
|
+
|
|
67
|
+
if (images.length === 0) {
|
|
68
|
+
console.log(" No images with measurable entropy found.");
|
|
69
|
+
console.log(" (Data URLs and cross-origin images without CORS are excluded)");
|
|
70
|
+
console.groupEnd();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Summary
|
|
75
|
+
const lowEntropy = images.filter((img) => img.isLowEntropy);
|
|
76
|
+
const normalEntropy = images.filter((img) => !img.isLowEntropy);
|
|
77
|
+
const lcpImage = images.find((img) => img.isLCP);
|
|
78
|
+
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log("%cSummary:", "font-weight: bold;");
|
|
81
|
+
console.log(` Total images analyzed: ${images.length}`);
|
|
82
|
+
console.log(` š¢ Normal entropy (LCP eligible): ${normalEntropy.length}`);
|
|
83
|
+
console.log(` š“ Low entropy (LCP ineligible): ${lowEntropy.length}`);
|
|
84
|
+
|
|
85
|
+
if (lcpImage) {
|
|
86
|
+
const icon = lcpImage.isLowEntropy ? "ā ļø" : "ā
";
|
|
87
|
+
console.log("");
|
|
88
|
+
console.log(`%c${icon} Current LCP image:`, "font-weight: bold;");
|
|
89
|
+
console.log(` ${lcpImage.shortSrc}`);
|
|
90
|
+
console.log(` BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Table
|
|
94
|
+
console.log("");
|
|
95
|
+
console.log("%cAll Images:", "font-weight: bold;");
|
|
96
|
+
|
|
97
|
+
const tableData = images
|
|
98
|
+
.sort((a, b) => b.bpp - a.bpp)
|
|
99
|
+
.map((img) => ({
|
|
100
|
+
Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
|
|
101
|
+
Dimensions: `${img.width}Ć${img.height}`,
|
|
102
|
+
Size: formatBytes(img.fileSize),
|
|
103
|
+
BPP: img.bpp.toFixed(4),
|
|
104
|
+
Entropy: img.isLowEntropy ? "š“ Low" : "š¢ Normal",
|
|
105
|
+
"LCP Eligible": img.lcpEligible ? "ā
" : "ā",
|
|
106
|
+
"Is LCP": img.isLCP ? "š" : "",
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
console.table(tableData);
|
|
110
|
+
|
|
111
|
+
// Warnings
|
|
112
|
+
if (lowEntropy.length > 0) {
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log("%cā ļø Low Entropy Images:", "font-weight: bold; color: #f59e0b;");
|
|
115
|
+
console.log(" These images will NOT be considered for LCP in Chrome 112+:");
|
|
116
|
+
lowEntropy.forEach((img) => {
|
|
117
|
+
console.log(` ⢠${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (lcpImage && lcpImage.isLowEntropy) {
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log("%cšØ Warning:", "font-weight: bold; color: #ef4444;");
|
|
124
|
+
console.log(" Your LCP image has low entropy and may be skipped by Chrome!");
|
|
125
|
+
console.log(" Chrome will use the next largest element instead.");
|
|
126
|
+
console.log("");
|
|
127
|
+
console.log("%cš” Solutions:", "font-weight: bold; color: #3b82f6;");
|
|
128
|
+
console.log(" ⢠Replace placeholder with actual content image");
|
|
129
|
+
console.log(" ⢠Use a text element as LCP instead");
|
|
130
|
+
console.log(" ⢠Ensure hero image loads with sufficient detail");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Elements for inspection
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log("%cš Inspect elements:", "font-weight: bold;");
|
|
136
|
+
images.forEach((img, i) => {
|
|
137
|
+
const icon = img.isLowEntropy ? "š“" : "š¢";
|
|
138
|
+
const lcpMark = img.isLCP ? " š LCP" : "";
|
|
139
|
+
console.log(` ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.groupEnd();
|
|
143
|
+
}, 100);
|
|
144
|
+
|
|
145
|
+
// Synchronous return for agent (buffered entries + DOM)
|
|
146
|
+
const lcpEntriesSync = performance.getEntriesByType("largest-contentful-paint");
|
|
147
|
+
const lcpEntrySync = lcpEntriesSync.at(-1);
|
|
148
|
+
const lcpElementSync = lcpEntrySync?.element ?? null;
|
|
149
|
+
const lcpUrlSync = lcpEntrySync?.url ?? null;
|
|
150
|
+
const imagesSync = [...document.images]
|
|
151
|
+
.filter((img) => { const src = img.currentSrc || img.src; return src && !src.startsWith("data:image"); })
|
|
152
|
+
.map((img) => {
|
|
153
|
+
const src = img.currentSrc || img.src;
|
|
154
|
+
const resource = performance.getEntriesByName(src)[0];
|
|
155
|
+
const fileSize = resource?.encodedBodySize || 0;
|
|
156
|
+
const pixels = img.naturalWidth * img.naturalHeight;
|
|
157
|
+
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
|
|
158
|
+
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
|
|
159
|
+
const isLCP = lcpElementSync === img || lcpUrlSync === src;
|
|
160
|
+
return {
|
|
161
|
+
url: src.split("/").pop()?.split("?")[0] || src,
|
|
162
|
+
width: img.naturalWidth,
|
|
163
|
+
height: img.naturalHeight,
|
|
164
|
+
fileSizeBytes: fileSize,
|
|
165
|
+
bpp: Math.round(bpp * 10000) / 10000,
|
|
166
|
+
isLowEntropy,
|
|
167
|
+
lcpEligible: !isLowEntropy && bpp > 0,
|
|
168
|
+
isLCP,
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
.filter((img) => img.bpp > 0);
|
|
172
|
+
const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length;
|
|
173
|
+
const lcpImageSync = imagesSync.find((img) => img.isLCP);
|
|
174
|
+
if (lcpElementSync) {
|
|
175
|
+
lcpElementSync.style.outline = "3px dashed lime";
|
|
176
|
+
lcpElementSync.style.outlineOffset = "2px";
|
|
177
|
+
}
|
|
178
|
+
const issuesSync = [];
|
|
179
|
+
if (lowEntropyCount > 0) {
|
|
180
|
+
issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
|
|
181
|
+
}
|
|
182
|
+
if (lcpImageSync?.isLowEntropy) {
|
|
183
|
+
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
script: "LCP-Image-Entropy",
|
|
187
|
+
status: "ok",
|
|
188
|
+
count: imagesSync.length,
|
|
189
|
+
details: {
|
|
190
|
+
totalImages: imagesSync.length,
|
|
191
|
+
lowEntropyCount,
|
|
192
|
+
lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
|
|
193
|
+
lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
|
|
194
|
+
},
|
|
195
|
+
items: imagesSync,
|
|
196
|
+
issues: issuesSync,
|
|
197
|
+
};
|
|
198
|
+
})();
|