webperf-snippets 0.4.0 → 0.5.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 +7 -5
- package/package.json +3 -3
- package/snippets/CoreWebVitals/CLS.js +1 -1
- package/snippets/CoreWebVitals/INP.js +1 -1
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +15 -1
- package/snippets/CoreWebVitals/LCP-Subparts.js +47 -18
- package/snippets/CoreWebVitals/LCP-Trail.js +35 -10
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +48 -24
- package/snippets/CoreWebVitals/LCP.js +37 -15
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +1 -1
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +1 -1
- package/snippets/Interaction/DOM-Size-and-Depth.js +1 -1
- package/snippets/Interaction/Forced-Synchronous-Layout.js +1 -1
- package/snippets/Interaction/Input-Latency-Breakdown.js +1 -1
- package/snippets/Interaction/Interactions.js +1 -1
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +1 -1
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +1 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +1 -1
- package/snippets/Interaction/Long-Animation-Frames.js +1 -1
- package/snippets/Interaction/LongTask.js +1 -1
- package/snippets/Interaction/Scroll-Performance.js +1 -1
- package/snippets/Loading/Back-Forward-Cache.js +19 -2
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +121 -456
- package/snippets/Loading/Cache-Strategy-Analysis.js +41 -20
- package/snippets/Loading/Client-Side-Redirect-Detection.js +11 -3
- package/snippets/Loading/Compression-Audit.js +10 -2
- package/snippets/Loading/Critical-CSS-Detection.js +11 -3
- package/snippets/Loading/Event-Processing-Time.js +1 -1
- package/snippets/Loading/FCP.js +12 -4
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +12 -4
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +10 -2
- package/snippets/Loading/Find-render-blocking-resources.js +11 -3
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +10 -2
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +10 -2
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +1 -1
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +1 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +1 -1
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +10 -2
- package/snippets/Loading/Prefetch-Resource-Validation.js +19 -5
- package/snippets/Loading/Priority-Hints-Audit.js +11 -3
- package/snippets/Loading/Resource-Hints-Validation.js +10 -2
- package/snippets/Loading/Resource-Hints.js +11 -3
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +1 -1
- package/snippets/Loading/Script-Loading.js +10 -2
- package/snippets/Loading/Server-Timing-Early-Hints.js +10 -2
- package/snippets/Loading/Service-Worker-Analysis.js +10 -2
- package/snippets/Loading/Speculation-Rules-Inspector.js +1 -1
- package/snippets/Loading/TTFB-Resources.js +11 -3
- package/snippets/Loading/TTFB-Sub-Parts.js +27 -1
- package/snippets/Loading/TTFB.js +1 -1
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +10 -2
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +10 -2
- package/snippets/Loading/Webfont-Usage-Analyzer.js +105 -30
- package/snippets/Media/Image-Element-Audit.js +19 -2
- package/snippets/Media/Oversized-Images.js +28 -3
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +48 -9
- package/snippets/Media/Video-Element-Audit.js +16 -10
- package/snippets/README.md +1 -1
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +1 -1
- package/snippets/SCHEMA.md +33 -14
- package/snippets/_shared/helpers.js +59 -0
- package/src/interactions.js +27 -0
- package/src/runner.js +5 -3
- package/src/tracking.js +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# webperf-snippets CLI
|
|
2
2
|
|
|
3
|
-
Run curated [WebPerf Snippets](https://webperf-snippets.
|
|
3
|
+
Run curated [WebPerf Snippets](https://webperf-snippets.net) headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes and gate CI on real performance budgets.
|
|
4
4
|
|
|
5
5
|
<img width="1820" height="1442" alt="webperf-snippets-CLI" src="https://github.com/user-attachments/assets/af7e6b02-8877-407e-87a4-db063468b5fb" />
|
|
6
6
|
|
|
@@ -38,13 +38,13 @@ Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP >
|
|
|
38
38
|
npx webperf-snippets https://web.dev
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings, third-party impact, fonts, cache; the list is in the [CLI page](https://webperf-snippets.
|
|
41
|
+
Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings, third-party impact, fonts, cache; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
npx webperf-snippets https://web.dev --workflow loading
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.
|
|
47
|
+
Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
npx webperf-snippets https://web.dev --workflow audit
|
|
@@ -77,6 +77,8 @@ npx webperf-snippets https://web.dev --snippet Interaction/Interactions --intera
|
|
|
77
77
|
|
|
78
78
|
The Interaction snippets (`Interactions`, `Input-Latency-Breakdown`, `LongTask`, `Forced-Synchronous-Layout`...) are installed before the first step and read after the last one, so their result holds what the steps caused.
|
|
79
79
|
|
|
80
|
+
The CLI waits for the browser to report the timing of the last interaction before it reads the snippets. A script that ends right after a click still reads the page before anything the click sets in motion later has happened, such as a request or an animation, so end it with a `wait` step when you want to measure that.
|
|
81
|
+
|
|
80
82
|
Interaction audit (long tasks, long animation frames, scroll, layout shifts, forced layouts, interaction latency). It scrolls the page, or runs your `--interact-script`; the steps that need a click are skipped when the script has none:
|
|
81
83
|
|
|
82
84
|
```bash
|
|
@@ -132,7 +134,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
132
134
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
133
135
|
"navMs": 1240,
|
|
134
136
|
"results": [],
|
|
135
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.
|
|
137
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.5.0" }
|
|
136
138
|
}
|
|
137
139
|
```
|
|
138
140
|
|
|
@@ -192,7 +194,7 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
192
194
|
|
|
193
195
|
### Follow-up steps
|
|
194
196
|
|
|
195
|
-
The results of a run can add steps on the same page load: LCP over 2.5 s adds `LCP-Subparts`, over 4 s `LCP-Trail`; an image or video LCP adds its entropy, image or video audit; CLS over 0.1 adds `Layout-Shift-Loading-and-Interaction`, over 0.25 also the lazy images, fonts, critical CSS and images without dimensions; TTFB over 600 ms adds `TTFB-Sub-Parts`; FCP over 1.8 s adds the render-blocking check. The full table is in the [CLI page](https://webperf-snippets.
|
|
197
|
+
The results of a run can add steps on the same page load: LCP over 2.5 s adds `LCP-Subparts`, over 4 s `LCP-Trail`; an image or video LCP adds its entropy, image or video audit; CLS over 0.1 adds `Layout-Shift-Loading-and-Interaction`, over 0.25 also the lazy images, fonts, critical CSS and images without dimensions; TTFB over 600 ms adds `TTFB-Sub-Parts`; FCP over 1.8 s adds the render-blocking check. The full table is in the [CLI page](https://webperf-snippets.net/CLI). Each snippet runs once, and a follow-up informs: its `error` issues do not change the exit code.
|
|
196
198
|
|
|
197
199
|
### Exit codes
|
|
198
200
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./src/bin.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test:e2e": "vitest run tests/e2e"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"vitest": "^
|
|
19
|
+
"vitest": "^4.1.11"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20.12.0"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"url": "git+https://github.com/nucliweb/webperf-snippets.git",
|
|
43
43
|
"directory": "cli"
|
|
44
44
|
},
|
|
45
|
-
"homepage": "https://webperf-snippets.
|
|
45
|
+
"homepage": "https://webperf-snippets.net",
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"playwright": ">=1.40.0"
|
|
48
48
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// LCP Image Entropy Check
|
|
2
|
-
// https://webperf-snippets.
|
|
2
|
+
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
5
|
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
@@ -195,6 +195,19 @@
|
|
|
195
195
|
if (lcpImageSync?.isLowEntropy) {
|
|
196
196
|
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
197
197
|
}
|
|
198
|
+
// Entropy applies to the LCP only when it is an <img> whose file size can be read; for text, or an
|
|
199
|
+
// image this snippet cannot read, it says so. The images of the page are still listed.
|
|
200
|
+
const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
|
|
201
|
+
if (lcpTag && !lcpImageSync) {
|
|
202
|
+
issuesSync.push({
|
|
203
|
+
severity: "info",
|
|
204
|
+
message: lcpTag === "img"
|
|
205
|
+
? "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
|
|
206
|
+
: lcpUrlSync
|
|
207
|
+
? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.`
|
|
208
|
+
: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
198
211
|
// The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
|
|
199
212
|
const MAX_ITEMS = 50;
|
|
200
213
|
const rankedImages = [...imagesSync].sort(
|
|
@@ -207,6 +220,7 @@
|
|
|
207
220
|
details: {
|
|
208
221
|
totalImages: imagesSync.length,
|
|
209
222
|
lowEntropyCount,
|
|
223
|
+
lcpElement: lcpTag,
|
|
210
224
|
lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
|
|
211
225
|
lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
|
|
212
226
|
},
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// LCP Subparts Analysis
|
|
2
|
-
// https://webperf-snippets.
|
|
2
|
+
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
|
+
// @shared isOwnRequest
|
|
6
|
+
// True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
|
|
7
|
+
// did not make it, so no snippet counts it.
|
|
8
|
+
function isOwnRequest(entry) {
|
|
9
|
+
return entry.name.endsWith("#webperf-snippets");
|
|
10
|
+
}
|
|
11
|
+
// @end-shared isOwnRequest
|
|
12
|
+
|
|
5
13
|
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
6
14
|
console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
|
|
7
15
|
return { script: "LCP-Subparts", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
|
|
@@ -25,6 +33,40 @@
|
|
|
25
33
|
{ name: "Element Render Delay", key: "elementRenderDelay", targetPercent: 10 },
|
|
26
34
|
];
|
|
27
35
|
|
|
36
|
+
// @shared uniqueSelector
|
|
37
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
38
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
39
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
40
|
+
function uniqueSelector(el) {
|
|
41
|
+
const root = el.getRootNode();
|
|
42
|
+
const matchesOne = (selector) => {
|
|
43
|
+
try {
|
|
44
|
+
return root.querySelectorAll(selector).length === 1;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const parts = [];
|
|
50
|
+
let node = el;
|
|
51
|
+
while (node && node.nodeType === 1) {
|
|
52
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
53
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const tag = node.localName;
|
|
57
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
58
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
59
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
60
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
61
|
+
}
|
|
62
|
+
parts.unshift(part);
|
|
63
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
64
|
+
node = node.parentElement;
|
|
65
|
+
}
|
|
66
|
+
return parts.join(" > ");
|
|
67
|
+
}
|
|
68
|
+
// @end-shared uniqueSelector
|
|
69
|
+
|
|
28
70
|
const getNavigationEntry = () => {
|
|
29
71
|
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
30
72
|
if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
|
|
@@ -41,7 +83,7 @@
|
|
|
41
83
|
if (!navEntry) return;
|
|
42
84
|
|
|
43
85
|
const lcpResEntry = performance
|
|
44
|
-
.getEntriesByType("resource")
|
|
86
|
+
.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
|
|
45
87
|
.find((e) => e.name === lcpEntry.url);
|
|
46
88
|
|
|
47
89
|
const activationStart = navEntry.activationStart || 0;
|
|
@@ -85,16 +127,9 @@
|
|
|
85
127
|
// Element info
|
|
86
128
|
if (lcpEntry.element) {
|
|
87
129
|
const el = lcpEntry.element;
|
|
88
|
-
let selector = el.tagName.toLowerCase();
|
|
89
|
-
if (el.id) selector = `#${el.id}`;
|
|
90
|
-
else if (el.className && typeof el.className === "string") {
|
|
91
|
-
const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
92
|
-
if (classes) selector = `${el.tagName.toLowerCase()}.${classes}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
130
|
console.log("");
|
|
96
131
|
console.log("%cLCP Element:", "font-weight: bold;");
|
|
97
|
-
console.log(` ${
|
|
132
|
+
console.log(` ${uniqueSelector(el)}`, el);
|
|
98
133
|
if (lcpEntry.url) {
|
|
99
134
|
const shortUrl = (() => {
|
|
100
135
|
try {
|
|
@@ -223,7 +258,7 @@
|
|
|
223
258
|
if (!navEntrySync) {
|
|
224
259
|
return { script: "LCP-Subparts", status: "error", error: "No navigation entry" };
|
|
225
260
|
}
|
|
226
|
-
const lcpResEntrySync = performance.getEntriesByType("resource")
|
|
261
|
+
const lcpResEntrySync = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
|
|
227
262
|
.find((e) => e.name === lcpEntry.url);
|
|
228
263
|
const activationStartSync = navEntrySync.activationStart || 0;
|
|
229
264
|
const ttfbSync = Math.max(0, navEntrySync.responseStart - activationStartSync);
|
|
@@ -249,13 +284,7 @@
|
|
|
249
284
|
const slowestPhaseSync = subPartsForRank.reduce((a, b) => a.value > b.value ? a : b).key;
|
|
250
285
|
let lcpSelectorSync = null;
|
|
251
286
|
if (lcpEntry.element) {
|
|
252
|
-
|
|
253
|
-
lcpSelectorSync = el.tagName.toLowerCase();
|
|
254
|
-
if (el.id) lcpSelectorSync = `#${el.id}`;
|
|
255
|
-
else if (el.className && typeof el.className === "string") {
|
|
256
|
-
const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
257
|
-
if (classes) lcpSelectorSync = `${el.tagName.toLowerCase()}.${classes}`;
|
|
258
|
-
}
|
|
287
|
+
lcpSelectorSync = uniqueSelector(lcpEntry.element);
|
|
259
288
|
}
|
|
260
289
|
const shortUrlSync = lcpEntry.url
|
|
261
290
|
? (() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// LCP Trail
|
|
2
2
|
// Tracks all LCP candidate elements during page load
|
|
3
|
-
// https://webperf-snippets.
|
|
3
|
+
// https://webperf-snippets.net
|
|
4
4
|
|
|
5
5
|
(async () => {
|
|
6
6
|
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
@@ -30,14 +30,39 @@
|
|
|
30
30
|
return navEntry?.activationStart || 0;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// @shared uniqueSelector
|
|
34
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
35
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
36
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
37
|
+
function uniqueSelector(el) {
|
|
38
|
+
const root = el.getRootNode();
|
|
39
|
+
const matchesOne = (selector) => {
|
|
40
|
+
try {
|
|
41
|
+
return root.querySelectorAll(selector).length === 1;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const parts = [];
|
|
47
|
+
let node = el;
|
|
48
|
+
while (node && node.nodeType === 1) {
|
|
49
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
50
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const tag = node.localName;
|
|
54
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
55
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
56
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
57
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
58
|
+
}
|
|
59
|
+
parts.unshift(part);
|
|
60
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
61
|
+
node = node.parentElement;
|
|
38
62
|
}
|
|
39
|
-
return
|
|
40
|
-
}
|
|
63
|
+
return parts.join(" > ");
|
|
64
|
+
}
|
|
65
|
+
// @end-shared uniqueSelector
|
|
41
66
|
|
|
42
67
|
const getElementInfo = (element, entry) => {
|
|
43
68
|
const tag = element.tagName.toLowerCase();
|
|
@@ -115,7 +140,7 @@
|
|
|
115
140
|
candidates.push({
|
|
116
141
|
index: candidates.length + 1,
|
|
117
142
|
element,
|
|
118
|
-
selector:
|
|
143
|
+
selector: uniqueSelector(element),
|
|
119
144
|
color,
|
|
120
145
|
name,
|
|
121
146
|
time: Math.max(0, entry.startTime - activationStart),
|
|
@@ -152,7 +177,7 @@
|
|
|
152
177
|
const el = entry.element;
|
|
153
178
|
if (!el || seenEls.has(el)) continue;
|
|
154
179
|
seenEls.add(el);
|
|
155
|
-
const selector =
|
|
180
|
+
const selector = uniqueSelector(el);
|
|
156
181
|
const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
|
|
157
182
|
const { type, url } = getElementInfo(el, entry);
|
|
158
183
|
syncCandidates.push({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// LCP Video Candidate
|
|
2
|
-
// https://webperf-snippets.
|
|
2
|
+
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
5
|
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
@@ -68,40 +68,55 @@
|
|
|
68
68
|
const lcpTime = Math.round(Math.max(0, lcp.startTime - activationStart));
|
|
69
69
|
|
|
70
70
|
// --- LCP is NOT a video ---
|
|
71
|
+
// The value of a non-video LCP belongs to the LCP snippet; this one only says what the element is
|
|
71
72
|
if (!element || element.tagName !== "VIDEO") {
|
|
72
|
-
const tag = element ?
|
|
73
|
-
const
|
|
73
|
+
const tag = element ? element.tagName.toLowerCase() : null;
|
|
74
|
+
const message = tag
|
|
75
|
+
? `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.`
|
|
76
|
+
: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it.";
|
|
74
77
|
console.log("%cLCP element is not a <video>", "font-weight: bold;");
|
|
75
78
|
console.log("");
|
|
76
|
-
console.log(`
|
|
77
|
-
console.log(` Tag : ${tag}`);
|
|
79
|
+
console.log(` Tag : ${tag ? `<${tag}>` : "(element no longer in DOM)"}`);
|
|
78
80
|
if (lcp.url) console.log(` URL : ${lcp.url}`);
|
|
79
81
|
if (element) console.log(" Element :", element);
|
|
82
|
+
console.log("");
|
|
83
|
+
console.log(` ${message}`);
|
|
80
84
|
console.groupEnd();
|
|
81
85
|
return {
|
|
82
86
|
script: "LCP-Video-Candidate",
|
|
83
87
|
status: "ok",
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
unit: "ms",
|
|
87
|
-
rating,
|
|
88
|
-
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
89
|
-
details: { isVideo: false },
|
|
90
|
-
issues: [],
|
|
88
|
+
details: { isVideo: false, lcpElement: tag, lcpUrl: lcp.url || null },
|
|
89
|
+
issues: [{ severity: "info", message }],
|
|
91
90
|
};
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
// --- LCP IS a video ---
|
|
95
94
|
const posterAttr = element.getAttribute("poster") || "";
|
|
96
95
|
const posterUrl = posterAttr ? normalizeUrl(posterAttr) : "";
|
|
97
|
-
const lcpUrl = lcp.url
|
|
96
|
+
const lcpUrl = lcp.url ? normalizeUrl(lcp.url) : "";
|
|
97
|
+
const videoUrls = [element.currentSrc, element.getAttribute("src"), ...Array.from(element.querySelectorAll("source"), (s) => s.getAttribute("src"))]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map(normalizeUrl);
|
|
100
|
+
|
|
101
|
+
// Chrome considers both the poster image and the first video frame as LCP candidates, and gives
|
|
102
|
+
// the entry the URL of the poster or of the video file. The URL tells them apart. A video without
|
|
103
|
+
// a poster can only be its first frame, and a URL that matches neither (a redirected poster) is
|
|
104
|
+
// taken as the poster.
|
|
105
|
+
const lcpSource =
|
|
106
|
+
lcpUrl && videoUrls.includes(lcpUrl) ? "first-frame"
|
|
107
|
+
: lcpUrl && lcpUrl === posterUrl ? "poster"
|
|
108
|
+
: !posterAttr ? "first-frame"
|
|
109
|
+
: lcpUrl ? "poster"
|
|
110
|
+
: "unknown";
|
|
111
|
+
const videoUrl = lcpSource === "first-frame" ? lcpUrl || videoUrls[0] || null : null;
|
|
98
112
|
|
|
99
|
-
// Chrome
|
|
100
|
-
//
|
|
101
|
-
const
|
|
113
|
+
// Chrome keeps reporting LCP candidates until the user interacts with the page, so a page left
|
|
114
|
+
// open can report one long after its load, which is not the LCP of the load
|
|
115
|
+
const loadEventEnd = performance.getEntriesByType("navigation")[0]?.loadEventEnd || 0;
|
|
116
|
+
const secondsAfterLoad = loadEventEnd > 0 ? Math.round((lcp.startTime - loadEventEnd) / 1000) : 0;
|
|
102
117
|
|
|
103
118
|
const rating = valueToRating(lcpTime);
|
|
104
|
-
const posterFormat = detectFormat(
|
|
119
|
+
const posterFormat = detectFormat(posterUrl);
|
|
105
120
|
const isModernFormat = ["avif", "webp", "jxl"].includes(posterFormat);
|
|
106
121
|
const isCrossOrigin = lcp.renderTime === 0 && lcp.loadTime > 0;
|
|
107
122
|
|
|
@@ -123,25 +138,32 @@
|
|
|
123
138
|
|
|
124
139
|
const issues = [];
|
|
125
140
|
|
|
141
|
+
if (secondsAfterLoad > 10) {
|
|
142
|
+
issues.push({
|
|
143
|
+
s: "warning",
|
|
144
|
+
msg: `The LCP candidate rendered ${secondsAfterLoad} s after the page loaded, because the page stayed open without interaction. Reload and run the snippet again to measure the LCP of the load`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
if (lcpSource === "first-frame") {
|
|
127
|
-
issues.push({ s: "info", msg: "LCP is the first video frame
|
|
149
|
+
issues.push({ s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
|
|
128
150
|
}
|
|
129
151
|
if (lcpSource === "first-frame" && (!autoplay || !muted)) {
|
|
130
152
|
issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
if (posterAttr && !posterPreload) {
|
|
134
|
-
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster
|
|
156
|
+
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
|
|
135
157
|
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
|
|
136
|
-
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high"
|
|
158
|
+
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
|
|
140
|
-
issues.push({ s: "info", msg: `Poster uses ${posterFormat}
|
|
162
|
+
issues.push({ s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
|
|
141
163
|
}
|
|
142
164
|
|
|
143
165
|
if (isCrossOrigin) {
|
|
144
|
-
issues.push({ s: "info", msg: "renderTime is 0
|
|
166
|
+
issues.push({ s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
if (!autoplay && preload === "none") {
|
|
@@ -159,7 +181,8 @@
|
|
|
159
181
|
console.log(` Render time : ${lcp.renderTime > 0 ? Math.round(lcp.renderTime) + " ms" : "0 (cross-origin — add Timing-Allow-Origin)"}`);
|
|
160
182
|
console.log(` Load time : ${Math.round(lcp.loadTime)} ms`);
|
|
161
183
|
console.log(` Size : ${Math.round(lcp.size)} px²`);
|
|
162
|
-
console.log(` Poster URL : ${
|
|
184
|
+
console.log(` Poster URL : ${posterUrl || "⚠️ (none)"}`);
|
|
185
|
+
if (videoUrl) console.log(` Video URL : ${videoUrl}`);
|
|
163
186
|
console.log(` Format : ${posterFormat}`);
|
|
164
187
|
|
|
165
188
|
// Video element
|
|
@@ -234,7 +257,8 @@
|
|
|
234
257
|
details: {
|
|
235
258
|
isVideo: true,
|
|
236
259
|
lcpSource,
|
|
237
|
-
posterUrl:
|
|
260
|
+
posterUrl: posterUrl || null,
|
|
261
|
+
videoUrl,
|
|
238
262
|
posterFormat,
|
|
239
263
|
posterPreloaded: !!posterPreload,
|
|
240
264
|
fetchpriorityOnPreload: posterPreload?.getAttribute("fetchpriority") ?? null,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// LCP Quick Check
|
|
2
|
-
// https://webperf-snippets.
|
|
2
|
+
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
5
|
if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
|
|
@@ -15,6 +15,40 @@
|
|
|
15
15
|
poor: { icon: "🔴", color: "#FF4E42" },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// @shared uniqueSelector
|
|
19
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
20
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
21
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
22
|
+
function uniqueSelector(el) {
|
|
23
|
+
const root = el.getRootNode();
|
|
24
|
+
const matchesOne = (selector) => {
|
|
25
|
+
try {
|
|
26
|
+
return root.querySelectorAll(selector).length === 1;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const parts = [];
|
|
32
|
+
let node = el;
|
|
33
|
+
while (node && node.nodeType === 1) {
|
|
34
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
35
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const tag = node.localName;
|
|
39
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
40
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
41
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
42
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
43
|
+
}
|
|
44
|
+
parts.unshift(part);
|
|
45
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
46
|
+
node = node.parentElement;
|
|
47
|
+
}
|
|
48
|
+
return parts.join(" > ");
|
|
49
|
+
}
|
|
50
|
+
// @end-shared uniqueSelector
|
|
51
|
+
|
|
18
52
|
const getActivationStart = () => {
|
|
19
53
|
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
20
54
|
return navEntry?.activationStart || 0;
|
|
@@ -38,14 +72,7 @@
|
|
|
38
72
|
console.log("");
|
|
39
73
|
console.log("%cLCP Element:", "font-weight: bold;");
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
if (element.id) selector = `#${element.id}`;
|
|
43
|
-
else if (element.className && typeof element.className === "string") {
|
|
44
|
-
const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
45
|
-
if (classes) selector = `${element.tagName.toLowerCase()}.${classes}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
console.log(` Element: ${selector}`, element);
|
|
75
|
+
console.log(` Element: ${uniqueSelector(element)}`, element);
|
|
49
76
|
|
|
50
77
|
const tagName = element.tagName.toLowerCase();
|
|
51
78
|
if (tagName === "img") {
|
|
@@ -108,12 +135,7 @@
|
|
|
108
135
|
let lcpSelector = null;
|
|
109
136
|
let lcpType = null;
|
|
110
137
|
if (lcpEl) {
|
|
111
|
-
lcpSelector = lcpEl
|
|
112
|
-
if (lcpEl.id) lcpSelector = `#${lcpEl.id}`;
|
|
113
|
-
else if (lcpEl.className && typeof lcpEl.className === "string") {
|
|
114
|
-
const classes = lcpEl.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
115
|
-
if (classes) lcpSelector = `${lcpEl.tagName.toLowerCase()}.${classes}`;
|
|
116
|
-
}
|
|
138
|
+
lcpSelector = uniqueSelector(lcpEl);
|
|
117
139
|
const tag = lcpEl.tagName.toLowerCase();
|
|
118
140
|
lcpType = tag === "img" ? "Image" : tag === "video" ? "Video poster" :
|
|
119
141
|
window.getComputedStyle(lcpEl).backgroundImage !== "none" ? "Background image" :
|