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
|
+
// LCP Subparts Analysis
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(async () => {
|
|
5
|
+
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
|
+
const formatPercent = (value, total) => `${Math.round((value / total) * 100)}%`;
|
|
7
|
+
|
|
8
|
+
const valueToRating = (ms) =>
|
|
9
|
+
ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
|
|
10
|
+
|
|
11
|
+
const RATING = {
|
|
12
|
+
good: { icon: "๐ข", color: "#0CCE6A" },
|
|
13
|
+
"needs-improvement": { icon: "๐ก", color: "#FFA400" },
|
|
14
|
+
poor: { icon: "๐ด", color: "#FF4E42" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SUB_PARTS = [
|
|
18
|
+
{ name: "Time to First Byte", key: "ttfb", target: 800 },
|
|
19
|
+
{ name: "Resource Load Delay", key: "resourceLoadDelay", targetPercent: 10 },
|
|
20
|
+
{ name: "Resource Load Time", key: "resourceLoadTime", targetPercent: 40 },
|
|
21
|
+
{ name: "Element Render Delay", key: "elementRenderDelay", targetPercent: 10 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const getNavigationEntry = () => {
|
|
25
|
+
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
26
|
+
if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
|
|
27
|
+
return navEntry;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const observer = new PerformanceObserver((list) => {
|
|
33
|
+
const lcpEntry = list.getEntries().at(-1);
|
|
34
|
+
if (!lcpEntry) return;
|
|
35
|
+
|
|
36
|
+
const navEntry = getNavigationEntry();
|
|
37
|
+
if (!navEntry) return;
|
|
38
|
+
|
|
39
|
+
const lcpResEntry = performance
|
|
40
|
+
.getEntriesByType("resource")
|
|
41
|
+
.find((e) => e.name === lcpEntry.url);
|
|
42
|
+
|
|
43
|
+
const activationStart = navEntry.activationStart || 0;
|
|
44
|
+
|
|
45
|
+
// Calculate sub-parts
|
|
46
|
+
const ttfb = Math.max(0, navEntry.responseStart - activationStart);
|
|
47
|
+
|
|
48
|
+
const lcpRequestStart = Math.max(
|
|
49
|
+
ttfb,
|
|
50
|
+
lcpResEntry
|
|
51
|
+
? (lcpResEntry.requestStart || lcpResEntry.startTime) - activationStart
|
|
52
|
+
: 0
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const lcpResponseEnd = Math.max(
|
|
56
|
+
lcpRequestStart,
|
|
57
|
+
lcpResEntry ? lcpResEntry.responseEnd - activationStart : 0
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const lcpRenderTime = Math.max(
|
|
61
|
+
lcpResponseEnd,
|
|
62
|
+
lcpEntry.startTime - activationStart
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const subPartValues = {
|
|
66
|
+
ttfb: ttfb,
|
|
67
|
+
resourceLoadDelay: lcpRequestStart - ttfb,
|
|
68
|
+
resourceLoadTime: lcpResponseEnd - lcpRequestStart,
|
|
69
|
+
elementRenderDelay: lcpRenderTime - lcpResponseEnd,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// LCP Rating
|
|
73
|
+
const rating = valueToRating(lcpRenderTime);
|
|
74
|
+
const { icon, color } = RATING[rating];
|
|
75
|
+
|
|
76
|
+
console.group(
|
|
77
|
+
`%cLCP: ${icon} ${formatMs(lcpRenderTime)} (${rating})`,
|
|
78
|
+
`color: ${color}; font-weight: bold; font-size: 14px;`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Element info
|
|
82
|
+
if (lcpEntry.element) {
|
|
83
|
+
const el = lcpEntry.element;
|
|
84
|
+
let selector = el.tagName.toLowerCase();
|
|
85
|
+
if (el.id) selector = `#${el.id}`;
|
|
86
|
+
else if (el.className && typeof el.className === "string") {
|
|
87
|
+
const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
88
|
+
if (classes) selector = `${el.tagName.toLowerCase()}.${classes}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log("%cLCP Element:", "font-weight: bold;");
|
|
93
|
+
console.log(` ${selector}`, el);
|
|
94
|
+
if (lcpEntry.url) {
|
|
95
|
+
const shortUrl = (() => {
|
|
96
|
+
try {
|
|
97
|
+
const u = new URL(lcpEntry.url);
|
|
98
|
+
return u.hostname !== location.hostname
|
|
99
|
+
? `${u.hostname}/โฆ/${u.pathname.split("/").pop()?.split("?")[0]}`
|
|
100
|
+
: u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
|
|
101
|
+
} catch { return lcpEntry.url; }
|
|
102
|
+
})();
|
|
103
|
+
console.log(` URL: ${shortUrl}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Highlight
|
|
107
|
+
el.style.outline = "3px dashed lime";
|
|
108
|
+
el.style.outlineOffset = "2px";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sub-parts table
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log("%cSub-Parts Breakdown:", "font-weight: bold;");
|
|
114
|
+
|
|
115
|
+
// Find the slowest phase
|
|
116
|
+
const phases = SUB_PARTS.map((part) => ({
|
|
117
|
+
...part,
|
|
118
|
+
value: subPartValues[part.key],
|
|
119
|
+
percent: (subPartValues[part.key] / lcpRenderTime) * 100,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const slowest = phases.reduce((a, b) => (a.value > b.value ? a : b));
|
|
123
|
+
|
|
124
|
+
const tableData = phases.map((part) => {
|
|
125
|
+
const isSlowest = part.key === slowest.key;
|
|
126
|
+
const isOverTarget = part.target
|
|
127
|
+
? part.value > part.target
|
|
128
|
+
: part.percent > part.targetPercent;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"Sub-part": isSlowest ? `โ ๏ธ ${part.name}` : part.name,
|
|
132
|
+
Time: formatMs(part.value),
|
|
133
|
+
"%": formatPercent(part.value, lcpRenderTime),
|
|
134
|
+
Status: isOverTarget ? "๐ด Over target" : "โ
OK",
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
console.table(tableData);
|
|
139
|
+
|
|
140
|
+
// Visual bar
|
|
141
|
+
const barWidth = 40;
|
|
142
|
+
const bars = phases.map((p) => {
|
|
143
|
+
const width = Math.max(1, Math.round((p.value / lcpRenderTime) * barWidth));
|
|
144
|
+
return { key: p.key, bar: width };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const ttfbBar = "โ".repeat(bars[0].bar);
|
|
148
|
+
const delayBar = "โ".repeat(bars[1].bar);
|
|
149
|
+
const loadBar = "โ".repeat(bars[2].bar);
|
|
150
|
+
const renderBar = "โ".repeat(bars[3].bar);
|
|
151
|
+
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log(` ${ttfbBar}${delayBar}${loadBar}${renderBar}`);
|
|
154
|
+
console.log(" โ TTFB โ Load Delay โ Load Time โ Render Delay");
|
|
155
|
+
|
|
156
|
+
// Recommendations based on slowest phase
|
|
157
|
+
console.log("");
|
|
158
|
+
console.log("%c๐ก Optimization Focus:", "font-weight: bold; color: #3b82f6;");
|
|
159
|
+
console.log(` Slowest phase: ${slowest.name} (${formatPercent(slowest.value, lcpRenderTime)})`);
|
|
160
|
+
|
|
161
|
+
if (slowest.key === "ttfb") {
|
|
162
|
+
console.log(" โ Use a CDN to reduce latency");
|
|
163
|
+
console.log(" โ Enable server-side caching");
|
|
164
|
+
console.log(" โ Optimize server response time");
|
|
165
|
+
} else if (slowest.key === "resourceLoadDelay") {
|
|
166
|
+
console.log(" โ Preload the LCP image: <link rel=\"preload\" as=\"image\" href=\"...\">");
|
|
167
|
+
console.log(" โ Remove render-blocking resources");
|
|
168
|
+
console.log(" โ Inline critical CSS");
|
|
169
|
+
} else if (slowest.key === "resourceLoadTime") {
|
|
170
|
+
console.log(" โ Compress and resize the LCP image");
|
|
171
|
+
console.log(" โ Use modern formats (WebP, AVIF)");
|
|
172
|
+
console.log(" โ Use a CDN for faster delivery");
|
|
173
|
+
} else if (slowest.key === "elementRenderDelay") {
|
|
174
|
+
console.log(" โ Reduce render-blocking JavaScript");
|
|
175
|
+
console.log(" โ Avoid client-side rendering for LCP element");
|
|
176
|
+
console.log(" โ Use fetchpriority=\"high\" on LCP image");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Performance entries for DevTools
|
|
180
|
+
SUB_PARTS.forEach((part) => performance.clearMeasures(part.name));
|
|
181
|
+
|
|
182
|
+
phases.forEach((part) => {
|
|
183
|
+
const startTimes = {
|
|
184
|
+
ttfb: 0,
|
|
185
|
+
resourceLoadDelay: ttfb,
|
|
186
|
+
resourceLoadTime: lcpRequestStart,
|
|
187
|
+
elementRenderDelay: lcpResponseEnd,
|
|
188
|
+
};
|
|
189
|
+
performance.measure(part.name, {
|
|
190
|
+
start: startTimes[part.key],
|
|
191
|
+
end: startTimes[part.key] + part.value,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log("");
|
|
196
|
+
console.log("%c๐ Measures added to Performance timeline", "color: #666;");
|
|
197
|
+
console.log(" Open DevTools โ Performance โ reload to see waterfall");
|
|
198
|
+
|
|
199
|
+
console.groupEnd();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
observer.observe({ type: "largest-contentful-paint", buffered: true });
|
|
203
|
+
|
|
204
|
+
console.log("%c๐ LCP Subparts Analysis Active", "font-weight: bold; font-size: 14px;");
|
|
205
|
+
console.log(" Waiting for LCP...");
|
|
206
|
+
|
|
207
|
+
// Return for agent โ collect via buffered observer (getEntriesByType does not
|
|
208
|
+
// expose largest-contentful-paint entries in Chrome without an active observer).
|
|
209
|
+
const lcpEntry = await new Promise((resolve) => {
|
|
210
|
+
const entries = [];
|
|
211
|
+
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
212
|
+
obs.observe({ type: "largest-contentful-paint", buffered: true });
|
|
213
|
+
setTimeout(() => { obs.disconnect(); resolve(entries.at(-1) ?? null); }, 100);
|
|
214
|
+
});
|
|
215
|
+
if (!lcpEntry) {
|
|
216
|
+
return { script: "LCP-Subparts", status: "error", error: "No LCP entries buffered" };
|
|
217
|
+
}
|
|
218
|
+
const navEntrySync = getNavigationEntry();
|
|
219
|
+
if (!navEntrySync) {
|
|
220
|
+
return { script: "LCP-Subparts", status: "error", error: "No navigation entry" };
|
|
221
|
+
}
|
|
222
|
+
const lcpResEntrySync = performance.getEntriesByType("resource")
|
|
223
|
+
.find((e) => e.name === lcpEntry.url);
|
|
224
|
+
const activationStartSync = navEntrySync.activationStart || 0;
|
|
225
|
+
const ttfbSync = Math.max(0, navEntrySync.responseStart - activationStartSync);
|
|
226
|
+
const lcpRequestStartSync = Math.max(ttfbSync,
|
|
227
|
+
lcpResEntrySync ? (lcpResEntrySync.requestStart || lcpResEntrySync.startTime) - activationStartSync : 0
|
|
228
|
+
);
|
|
229
|
+
const lcpResponseEndSync = Math.max(lcpRequestStartSync,
|
|
230
|
+
lcpResEntrySync ? lcpResEntrySync.responseEnd - activationStartSync : 0
|
|
231
|
+
);
|
|
232
|
+
const lcpRenderTimeSync = Math.max(lcpResponseEndSync, lcpEntry.startTime - activationStartSync);
|
|
233
|
+
const totalSync = Math.round(lcpRenderTimeSync);
|
|
234
|
+
const ratingSync = valueToRating(totalSync);
|
|
235
|
+
const ttfbVal = Math.round(ttfbSync);
|
|
236
|
+
const loadDelayVal = Math.round(lcpRequestStartSync - ttfbSync);
|
|
237
|
+
const loadTimeVal = Math.round(lcpResponseEndSync - lcpRequestStartSync);
|
|
238
|
+
const renderDelayVal = Math.round(lcpRenderTimeSync - lcpResponseEndSync);
|
|
239
|
+
const subPartsForRank = [
|
|
240
|
+
{ key: "ttfb", value: ttfbVal },
|
|
241
|
+
{ key: "resourceLoadDelay", value: loadDelayVal },
|
|
242
|
+
{ key: "resourceLoadTime", value: loadTimeVal },
|
|
243
|
+
{ key: "elementRenderDelay", value: renderDelayVal },
|
|
244
|
+
];
|
|
245
|
+
const slowestPhaseSync = subPartsForRank.reduce((a, b) => a.value > b.value ? a : b).key;
|
|
246
|
+
let lcpSelectorSync = null;
|
|
247
|
+
if (lcpEntry.element) {
|
|
248
|
+
const el = lcpEntry.element;
|
|
249
|
+
lcpSelectorSync = el.tagName.toLowerCase();
|
|
250
|
+
if (el.id) lcpSelectorSync = `#${el.id}`;
|
|
251
|
+
else if (el.className && typeof el.className === "string") {
|
|
252
|
+
const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
253
|
+
if (classes) lcpSelectorSync = `${el.tagName.toLowerCase()}.${classes}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const shortUrlSync = lcpEntry.url
|
|
257
|
+
? (() => {
|
|
258
|
+
try {
|
|
259
|
+
const u = new URL(lcpEntry.url);
|
|
260
|
+
return u.hostname !== location.hostname
|
|
261
|
+
? `${u.hostname}/โฆ/${u.pathname.split("/").pop()?.split("?")[0]}`
|
|
262
|
+
: u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
|
|
263
|
+
} catch { return lcpEntry.url; }
|
|
264
|
+
})()
|
|
265
|
+
: null;
|
|
266
|
+
return {
|
|
267
|
+
script: "LCP-Subparts",
|
|
268
|
+
status: "ok",
|
|
269
|
+
metric: "LCP",
|
|
270
|
+
value: totalSync,
|
|
271
|
+
unit: "ms",
|
|
272
|
+
rating: ratingSync,
|
|
273
|
+
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
274
|
+
details: {
|
|
275
|
+
element: lcpSelectorSync,
|
|
276
|
+
url: shortUrlSync,
|
|
277
|
+
subParts: {
|
|
278
|
+
ttfb: { value: ttfbVal, percent: Math.round((ttfbVal / totalSync) * 100), overTarget: ttfbVal > 800 },
|
|
279
|
+
resourceLoadDelay: { value: loadDelayVal, percent: Math.round((loadDelayVal / totalSync) * 100), overTarget: (loadDelayVal / totalSync) * 100 > 10 },
|
|
280
|
+
resourceLoadTime: { value: loadTimeVal, percent: Math.round((loadTimeVal / totalSync) * 100), overTarget: (loadTimeVal / totalSync) * 100 > 40 },
|
|
281
|
+
elementRenderDelay: { value: renderDelayVal, percent: Math.round((renderDelayVal / totalSync) * 100), overTarget: (renderDelayVal / totalSync) * 100 > 10 },
|
|
282
|
+
},
|
|
283
|
+
slowestPhase: slowestPhaseSync,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
})();
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// LCP Trail
|
|
2
|
+
// Tracks all LCP candidate elements during page load
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(() => {
|
|
6
|
+
const PALETTE = [
|
|
7
|
+
{ color: "#EF4444", name: "Red" },
|
|
8
|
+
{ color: "#F97316", name: "Orange" },
|
|
9
|
+
{ color: "#22C55E", name: "Green" },
|
|
10
|
+
{ color: "#3B82F6", name: "Blue" },
|
|
11
|
+
{ color: "#A855F7", name: "Purple" },
|
|
12
|
+
{ color: "#EC4899", name: "Pink" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const valueToRating = (ms) =>
|
|
16
|
+
ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
|
|
17
|
+
|
|
18
|
+
const RATING = {
|
|
19
|
+
good: { icon: "๐ข", color: "#0CCE6A" },
|
|
20
|
+
"needs-improvement": { icon: "๐ก", color: "#FFA400" },
|
|
21
|
+
poor: { icon: "๐ด", color: "#FF4E42" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const getActivationStart = () => {
|
|
25
|
+
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
26
|
+
return navEntry?.activationStart || 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getSelector = (element) => {
|
|
30
|
+
if (element.id) return `#${element.id}`;
|
|
31
|
+
if (element.className && typeof element.className === "string") {
|
|
32
|
+
const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
33
|
+
if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
|
|
34
|
+
}
|
|
35
|
+
return element.tagName.toLowerCase();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getElementInfo = (element, entry) => {
|
|
39
|
+
const tag = element.tagName.toLowerCase();
|
|
40
|
+
if (tag === "img") return { type: "Image", url: entry.url || element.src };
|
|
41
|
+
if (tag === "video") return { type: "Video poster", url: entry.url || element.poster };
|
|
42
|
+
if (window.getComputedStyle(element).backgroundImage !== "none") return { type: "Background image", url: entry.url };
|
|
43
|
+
return { type: tag === "h1" || tag === "p" ? "Text block" : tag };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const candidates = [];
|
|
47
|
+
|
|
48
|
+
const logTrail = () => {
|
|
49
|
+
const current = candidates[candidates.length - 1];
|
|
50
|
+
if (!current) return;
|
|
51
|
+
|
|
52
|
+
const rating = valueToRating(current.time);
|
|
53
|
+
const { icon, color: ratingColor } = RATING[rating];
|
|
54
|
+
|
|
55
|
+
console.group(
|
|
56
|
+
`%cLCP: ${icon} ${(current.time / 1000).toFixed(2)}s (${rating})`,
|
|
57
|
+
`color: ${ratingColor}; font-weight: bold; font-size: 14px;`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Current LCP element attribution
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log("%cLCP Element:", "font-weight: bold;");
|
|
63
|
+
console.log(` Element: ${current.selector}`, current.element);
|
|
64
|
+
|
|
65
|
+
const { type, url } = getElementInfo(current.element, current.entry);
|
|
66
|
+
console.log(` Type: ${type}`);
|
|
67
|
+
if (url) console.log(` URL: ${url}`);
|
|
68
|
+
if (current.element.naturalWidth) {
|
|
69
|
+
console.log(
|
|
70
|
+
` Dimensions: ${current.element.naturalWidth}ร${current.element.naturalHeight}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (current.entry.size) {
|
|
74
|
+
console.log(` Size: ${current.entry.size.toLocaleString()} pxยฒ`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Trail legend
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log("%cCandidates Trail:", "font-weight: bold;");
|
|
80
|
+
candidates.forEach(({ index, selector, color, name, time, element }) => {
|
|
81
|
+
const isCurrent = index === candidates.length;
|
|
82
|
+
console.log(
|
|
83
|
+
`%c โ ${index}. ${selector}`,
|
|
84
|
+
`color: ${color}; font-weight: ${isCurrent ? "bold" : "normal"};`,
|
|
85
|
+
`| ${(time / 1000).toFixed(2)}s โ ${name}${isCurrent ? " โ LCP" : ""}`,
|
|
86
|
+
element
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log("");
|
|
91
|
+
console.log(
|
|
92
|
+
"%cโ Each candidate highlighted with a colored dashed outline",
|
|
93
|
+
"color: #22c55e;"
|
|
94
|
+
);
|
|
95
|
+
console.groupEnd();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const observer = new PerformanceObserver((list) => {
|
|
99
|
+
const activationStart = getActivationStart();
|
|
100
|
+
const seen = new Set(candidates.map((c) => c.element));
|
|
101
|
+
|
|
102
|
+
for (const entry of list.getEntries()) {
|
|
103
|
+
const { element } = entry;
|
|
104
|
+
if (!element || seen.has(element)) continue;
|
|
105
|
+
|
|
106
|
+
const { color, name } = PALETTE[candidates.length % PALETTE.length];
|
|
107
|
+
|
|
108
|
+
element.style.outline = `3px dashed ${color}`;
|
|
109
|
+
element.style.outlineOffset = "2px";
|
|
110
|
+
|
|
111
|
+
candidates.push({
|
|
112
|
+
index: candidates.length + 1,
|
|
113
|
+
element,
|
|
114
|
+
selector: getSelector(element),
|
|
115
|
+
color,
|
|
116
|
+
name,
|
|
117
|
+
time: Math.max(0, entry.startTime - activationStart),
|
|
118
|
+
entry,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
seen.add(element);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logTrail();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
observer.observe({ type: "largest-contentful-paint", buffered: true });
|
|
128
|
+
|
|
129
|
+
console.log("%cโฑ๏ธ LCP Trail Active", "font-weight: bold; font-size: 14px;");
|
|
130
|
+
console.log(" Highlights all LCP candidate elements with distinct colors.");
|
|
131
|
+
|
|
132
|
+
// Synchronous return for agent (buffered entries)
|
|
133
|
+
const trailEntries = performance.getEntriesByType("largest-contentful-paint");
|
|
134
|
+
if (trailEntries.length === 0) {
|
|
135
|
+
return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
|
|
136
|
+
}
|
|
137
|
+
const trailActivationStart = getActivationStart();
|
|
138
|
+
const seenEls = new Set();
|
|
139
|
+
const syncCandidates = [];
|
|
140
|
+
for (const entry of trailEntries) {
|
|
141
|
+
const el = entry.element;
|
|
142
|
+
if (!el || seenEls.has(el)) continue;
|
|
143
|
+
seenEls.add(el);
|
|
144
|
+
const selector = getSelector(el);
|
|
145
|
+
const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
|
|
146
|
+
const { type, url } = getElementInfo(el, entry);
|
|
147
|
+
syncCandidates.push({
|
|
148
|
+
index: syncCandidates.length + 1,
|
|
149
|
+
selector,
|
|
150
|
+
time,
|
|
151
|
+
elementType: type,
|
|
152
|
+
...(url ? {
|
|
153
|
+
url: (() => {
|
|
154
|
+
try {
|
|
155
|
+
const u = new URL(url);
|
|
156
|
+
return u.hostname !== location.hostname
|
|
157
|
+
? `${u.hostname}/โฆ/${u.pathname.split("/").pop()?.split("?")[0]}`
|
|
158
|
+
: u.pathname.split("/").pop()?.split("?")[0] || url;
|
|
159
|
+
} catch { return url; }
|
|
160
|
+
})(),
|
|
161
|
+
} : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (syncCandidates.length === 0) {
|
|
165
|
+
return { script: "LCP-Trail", status: "error", error: "No LCP elements in DOM" };
|
|
166
|
+
}
|
|
167
|
+
const lastCandidate = syncCandidates.at(-1);
|
|
168
|
+
const trailValue = lastCandidate.time;
|
|
169
|
+
const trailRating = valueToRating(trailValue);
|
|
170
|
+
return {
|
|
171
|
+
script: "LCP-Trail",
|
|
172
|
+
status: "ok",
|
|
173
|
+
metric: "LCP",
|
|
174
|
+
value: trailValue,
|
|
175
|
+
unit: "ms",
|
|
176
|
+
rating: trailRating,
|
|
177
|
+
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
178
|
+
details: {
|
|
179
|
+
candidateCount: syncCandidates.length,
|
|
180
|
+
finalElement: lastCandidate.selector,
|
|
181
|
+
candidates: syncCandidates,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
})();
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// LCP Video Candidate
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
|
|
6
|
+
|
|
7
|
+
if (lcpEntries.length === 0) {
|
|
8
|
+
console.warn(
|
|
9
|
+
"โ ๏ธ No LCP entries found. Run this snippet before interacting with the page, or reload and run it immediately.",
|
|
10
|
+
);
|
|
11
|
+
return { script: "LCP-Video-Candidate", status: "error", error: "No LCP entries found" };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const lcp = lcpEntries[lcpEntries.length - 1];
|
|
15
|
+
const element = lcp.element;
|
|
16
|
+
|
|
17
|
+
function valueToRating(ms) {
|
|
18
|
+
return ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const RATING = {
|
|
22
|
+
good: { icon: "๐ข", label: "Good (โค 2.5 s)" },
|
|
23
|
+
"needs-improvement": { icon: "๐ก", label: "Needs Improvement (โค 4 s)" },
|
|
24
|
+
poor: { icon: "๐ด", label: "Poor (> 4 s)" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function detectFormat(url) {
|
|
28
|
+
if (!url) return "unknown";
|
|
29
|
+
const path = url.toLowerCase().split("?")[0];
|
|
30
|
+
const ext = path.match(/\.(avif|webp|jxl|png|gif|jpg|jpeg|svg)(?:[?#]|$)/);
|
|
31
|
+
if (ext) return ext[1] === "jpeg" ? "jpg" : ext[1];
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeUrl(url) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(url, location.origin).href;
|
|
38
|
+
} catch {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.group("%c๐ฌ LCP Video Candidate", "font-weight: bold; font-size: 14px;");
|
|
44
|
+
console.log("");
|
|
45
|
+
|
|
46
|
+
const activationStart = (() => {
|
|
47
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
48
|
+
return nav?.activationStart || 0;
|
|
49
|
+
})();
|
|
50
|
+
const lcpTime = Math.round(Math.max(0, lcp.startTime - activationStart));
|
|
51
|
+
|
|
52
|
+
// --- LCP is NOT a video ---
|
|
53
|
+
if (!element || element.tagName !== "VIDEO") {
|
|
54
|
+
const tag = element ? `<${element.tagName.toLowerCase()}>` : "(element no longer in DOM)";
|
|
55
|
+
const rating = valueToRating(lcpTime);
|
|
56
|
+
console.log("%cLCP element is not a <video>", "font-weight: bold;");
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log(` LCP time : ${lcpTime} ms ${RATING[rating].icon} ${RATING[rating].label}`);
|
|
59
|
+
console.log(` Tag : ${tag}`);
|
|
60
|
+
if (lcp.url) console.log(` URL : ${lcp.url}`);
|
|
61
|
+
if (element) console.log(" Element :", element);
|
|
62
|
+
console.groupEnd();
|
|
63
|
+
return {
|
|
64
|
+
script: "LCP-Video-Candidate",
|
|
65
|
+
status: "ok",
|
|
66
|
+
metric: "LCP",
|
|
67
|
+
value: lcpTime,
|
|
68
|
+
unit: "ms",
|
|
69
|
+
rating,
|
|
70
|
+
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
71
|
+
details: { isVideo: false },
|
|
72
|
+
issues: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- LCP IS a video ---
|
|
77
|
+
const posterAttr = element.getAttribute("poster") || "";
|
|
78
|
+
const posterUrl = posterAttr ? normalizeUrl(posterAttr) : "";
|
|
79
|
+
const lcpUrl = lcp.url || "";
|
|
80
|
+
|
|
81
|
+
// Chrome considers both poster image and first video frame as LCP candidates.
|
|
82
|
+
// lcpUrl is set when the LCP came from the poster; empty when from the first frame.
|
|
83
|
+
const lcpSource = lcpUrl ? "poster" : posterAttr ? "unknown" : "first-frame";
|
|
84
|
+
|
|
85
|
+
const rating = valueToRating(lcpTime);
|
|
86
|
+
const posterFormat = detectFormat(lcpUrl || posterUrl);
|
|
87
|
+
const isModernFormat = ["avif", "webp", "jxl"].includes(posterFormat);
|
|
88
|
+
const isCrossOrigin = lcp.renderTime === 0 && lcp.loadTime > 0;
|
|
89
|
+
|
|
90
|
+
const preloadLinks = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
|
|
91
|
+
const posterPreload = preloadLinks.find((link) => {
|
|
92
|
+
const href = link.getAttribute("href");
|
|
93
|
+
if (!href) return false;
|
|
94
|
+
try {
|
|
95
|
+
return normalizeUrl(href) === posterUrl || normalizeUrl(href) === lcpUrl;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}) ?? null;
|
|
100
|
+
|
|
101
|
+
const preload = element.getAttribute("preload");
|
|
102
|
+
const autoplay = element.hasAttribute("autoplay");
|
|
103
|
+
const muted = element.hasAttribute("muted") || element.muted;
|
|
104
|
+
const playsinline = element.hasAttribute("playsinline");
|
|
105
|
+
|
|
106
|
+
const issues = [];
|
|
107
|
+
|
|
108
|
+
if (lcpSource === "first-frame") {
|
|
109
|
+
issues.push({ s: "info", msg: "LCP is the first video frame โ adding a poster gives explicit control over the LCP image" });
|
|
110
|
+
}
|
|
111
|
+
if (lcpSource === "first-frame" && (!autoplay || !muted)) {
|
|
112
|
+
issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (posterAttr && !posterPreload) {
|
|
116
|
+
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster โ browser discovers it late' });
|
|
117
|
+
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
|
|
118
|
+
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high" โ may be deprioritised' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
|
|
122
|
+
issues.push({ s: "info", msg: `Poster uses ${posterFormat} โ AVIF or WebP would reduce file size and LCP load time` });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isCrossOrigin) {
|
|
126
|
+
issues.push({ s: "info", msg: "renderTime is 0 โ resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!autoplay && preload === "none") {
|
|
130
|
+
issues.push({ s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Summary
|
|
134
|
+
console.log("%cโ
LCP element is a <video>", "color: #22c55e; font-weight: bold;");
|
|
135
|
+
console.log("");
|
|
136
|
+
|
|
137
|
+
// LCP metrics
|
|
138
|
+
console.log("%cLCP Metrics", "font-weight: bold;");
|
|
139
|
+
console.log(` LCP time : ${lcpTime} ms ${RATING[rating].icon} ${RATING[rating].label}`);
|
|
140
|
+
console.log(` LCP source : ${lcpSource === "poster" ? "poster image" : lcpSource === "first-frame" ? "first video frame" : "unknown"}`);
|
|
141
|
+
console.log(` Render time : ${lcp.renderTime > 0 ? Math.round(lcp.renderTime) + " ms" : "0 (cross-origin โ add Timing-Allow-Origin)"}`);
|
|
142
|
+
console.log(` Load time : ${Math.round(lcp.loadTime)} ms`);
|
|
143
|
+
console.log(` Size : ${Math.round(lcp.size)} pxยฒ`);
|
|
144
|
+
console.log(` Poster URL : ${lcpUrl || posterUrl || "โ ๏ธ (none)"}`);
|
|
145
|
+
console.log(` Format : ${posterFormat}`);
|
|
146
|
+
|
|
147
|
+
// Video element
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log("%cVideo Element", "font-weight: bold;");
|
|
150
|
+
console.log(` poster : ${posterAttr || "โ ๏ธ (not set)"}`);
|
|
151
|
+
console.log(` preload : ${preload ?? "(not set)"}`);
|
|
152
|
+
console.log(` autoplay : ${autoplay ? "โ" : "โ"}`);
|
|
153
|
+
console.log(` muted : ${muted ? "โ" : "โ"}`);
|
|
154
|
+
console.log(` playsinline : ${playsinline ? "โ" : "โ"}`);
|
|
155
|
+
console.log(" Element :", element);
|
|
156
|
+
|
|
157
|
+
// Preload link
|
|
158
|
+
console.log("");
|
|
159
|
+
console.log("%cPreload Link", "font-weight: bold;");
|
|
160
|
+
if (posterPreload) {
|
|
161
|
+
const fp = posterPreload.getAttribute("fetchpriority");
|
|
162
|
+
console.log(` Status : โ
found`);
|
|
163
|
+
console.log(` fetchpriority: ${fp ?? "โ ๏ธ (not set)"}`);
|
|
164
|
+
console.log(" Element :", posterPreload);
|
|
165
|
+
} else {
|
|
166
|
+
console.log(` Status : โ ๏ธ not found`);
|
|
167
|
+
if (posterAttr) {
|
|
168
|
+
console.log(
|
|
169
|
+
` Recommended : <link rel="preload" as="image" href="${posterAttr}" fetchpriority="high">`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Issues
|
|
175
|
+
if (issues.length > 0) {
|
|
176
|
+
const totalErrors = issues.filter((i) => i.s === "error").length;
|
|
177
|
+
const totalWarnings = issues.filter((i) => i.s === "warning").length;
|
|
178
|
+
const totalInfos = issues.filter((i) => i.s === "info").length;
|
|
179
|
+
console.log("");
|
|
180
|
+
console.group(
|
|
181
|
+
`%cโ ๏ธ Issues (${totalErrors} errors ยท ${totalWarnings} warnings ยท ${totalInfos} info)`,
|
|
182
|
+
"color: #ef4444; font-weight: bold;",
|
|
183
|
+
);
|
|
184
|
+
issues.forEach((issue) => {
|
|
185
|
+
const prefix = issue.s === "error" ? "๐ด" : issue.s === "warning" ? "โ ๏ธ" : "โน๏ธ";
|
|
186
|
+
console.log(`${prefix} ${issue.msg}`);
|
|
187
|
+
});
|
|
188
|
+
console.groupEnd();
|
|
189
|
+
} else {
|
|
190
|
+
console.log("");
|
|
191
|
+
console.log("%cโ
No issues found.", "color: #22c55e; font-weight: bold;");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Quick reference
|
|
195
|
+
console.log("");
|
|
196
|
+
console.group("%c๐ Quick Reference", "color: #3b82f6; font-weight: bold;");
|
|
197
|
+
console.log("");
|
|
198
|
+
console.log("%c โ
Optimal video LCP setup:", "color: #22c55e;");
|
|
199
|
+
const ref = posterAttr || "poster.avif";
|
|
200
|
+
console.log(
|
|
201
|
+
`%c <link rel="preload" as="image" href="${ref}" fetchpriority="high">\n <video autoplay muted playsinline loop poster="${ref}" width="1280" height="720">\n <source src="hero.av1.webm" type="video/webm; codecs=av01.0.04M.08">\n <source src="hero.mp4" type="video/mp4">\n </video>`,
|
|
202
|
+
"font-family: monospace;",
|
|
203
|
+
);
|
|
204
|
+
console.groupEnd();
|
|
205
|
+
|
|
206
|
+
console.groupEnd();
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
script: "LCP-Video-Candidate",
|
|
210
|
+
status: "ok",
|
|
211
|
+
metric: "LCP",
|
|
212
|
+
value: lcpTime,
|
|
213
|
+
unit: "ms",
|
|
214
|
+
rating,
|
|
215
|
+
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
216
|
+
details: {
|
|
217
|
+
isVideo: true,
|
|
218
|
+
lcpSource,
|
|
219
|
+
posterUrl: lcpUrl || posterUrl || null,
|
|
220
|
+
posterFormat,
|
|
221
|
+
posterPreloaded: !!posterPreload,
|
|
222
|
+
fetchpriorityOnPreload: posterPreload?.getAttribute("fetchpriority") ?? null,
|
|
223
|
+
isCrossOrigin,
|
|
224
|
+
videoAttributes: { autoplay, muted, playsinline, preload },
|
|
225
|
+
},
|
|
226
|
+
issues: issues.map((i) => ({ severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
|
|
227
|
+
};
|
|
228
|
+
})();
|