webperf-snippets 0.4.1 → 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 +5 -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/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
|
|
@@ -134,7 +134,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
134
134
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
135
135
|
"navMs": 1240,
|
|
136
136
|
"results": [],
|
|
137
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.
|
|
137
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.5.0" }
|
|
138
138
|
}
|
|
139
139
|
```
|
|
140
140
|
|
|
@@ -194,7 +194,7 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
194
194
|
|
|
195
195
|
### Follow-up steps
|
|
196
196
|
|
|
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.
|
|
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.
|
|
198
198
|
|
|
199
199
|
### Exit codes
|
|
200
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" :
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
// Back/Forward Cache (bfcache) Analysis
|
|
2
|
-
// https://webperf-snippets.
|
|
2
|
+
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(async () => {
|
|
5
|
+
// @shared ownRequestUrl
|
|
6
|
+
// The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
|
|
7
|
+
// new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
|
|
8
|
+
// comes from the cache, but it stays in the name of the Resource Timing entry of the request,
|
|
9
|
+
// which is how isOwnRequest leaves these requests out of what the page loaded.
|
|
10
|
+
function ownRequestUrl(url) {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(url, location.href);
|
|
13
|
+
if (!/^https?:$/.test(parsed.protocol)) return url;
|
|
14
|
+
parsed.hash = "webperf-snippets";
|
|
15
|
+
return parsed.href;
|
|
16
|
+
} catch {
|
|
17
|
+
return url;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// @end-shared ownRequestUrl
|
|
21
|
+
|
|
5
22
|
// Only what the browser can report is analysed. Several blockers cannot be detected from
|
|
6
23
|
// JavaScript (unload listeners added with addEventListener, open WebSocket, BroadcastChannel
|
|
7
24
|
// or IndexedDB connections). After a back navigation, the NotRestoredReasons API reports
|
|
@@ -87,7 +104,7 @@
|
|
|
87
104
|
// exposes the header; the value can differ from the original navigation.
|
|
88
105
|
if (/^https?:$/.test(location.protocol)) {
|
|
89
106
|
try {
|
|
90
|
-
const res = await fetch(location.href, { method: 'HEAD', credentials: 'same-origin' });
|
|
107
|
+
const res = await fetch(ownRequestUrl(location.href), { method: 'HEAD', credentials: 'same-origin' });
|
|
91
108
|
const cacheControl = res.headers.get('cache-control') || '';
|
|
92
109
|
if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) {
|
|
93
110
|
issues.push({
|