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
+ // 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
+ })();