webperf-snippets 0.4.1 → 1.0.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 (62) hide show
  1. package/README.md +5 -5
  2. package/package.json +3 -3
  3. package/snippets/CoreWebVitals/CLS.js +1 -1
  4. package/snippets/CoreWebVitals/INP.js +1 -1
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +15 -1
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +47 -18
  7. package/snippets/CoreWebVitals/LCP-Trail.js +35 -10
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +48 -24
  9. package/snippets/CoreWebVitals/LCP.js +37 -15
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +1 -1
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +1 -1
  12. package/snippets/Interaction/DOM-Size-and-Depth.js +1 -1
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +1 -1
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +1 -1
  15. package/snippets/Interaction/Interactions.js +1 -1
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +1 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +1 -1
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +1 -1
  19. package/snippets/Interaction/Long-Animation-Frames.js +1 -1
  20. package/snippets/Interaction/LongTask.js +1 -1
  21. package/snippets/Interaction/Scroll-Performance.js +1 -1
  22. package/snippets/Loading/Back-Forward-Cache.js +19 -2
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +121 -456
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +41 -20
  25. package/snippets/Loading/Client-Side-Redirect-Detection.js +11 -3
  26. package/snippets/Loading/Compression-Audit.js +10 -2
  27. package/snippets/Loading/Critical-CSS-Detection.js +11 -3
  28. package/snippets/Loading/Event-Processing-Time.js +1 -1
  29. package/snippets/Loading/FCP.js +12 -4
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +12 -4
  31. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  32. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +10 -2
  33. package/snippets/Loading/Find-render-blocking-resources.js +11 -3
  34. package/snippets/Loading/First-And-Third-Party-Script-Info.js +10 -2
  35. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +10 -2
  36. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +1 -1
  37. package/snippets/Loading/Inline-CSS-Info-and-Size.js +1 -1
  38. package/snippets/Loading/Inline-Script-Info-and-Size.js +1 -1
  39. package/snippets/Loading/JS-Execution-Time-Breakdown.js +10 -2
  40. package/snippets/Loading/Prefetch-Resource-Validation.js +19 -5
  41. package/snippets/Loading/Priority-Hints-Audit.js +11 -3
  42. package/snippets/Loading/Resource-Hints-Validation.js +10 -2
  43. package/snippets/Loading/Resource-Hints.js +11 -3
  44. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +1 -1
  45. package/snippets/Loading/Script-Loading.js +10 -2
  46. package/snippets/Loading/Server-Timing-Early-Hints.js +10 -2
  47. package/snippets/Loading/Service-Worker-Analysis.js +10 -2
  48. package/snippets/Loading/Speculation-Rules-Inspector.js +1 -1
  49. package/snippets/Loading/TTFB-Resources.js +11 -3
  50. package/snippets/Loading/TTFB-Sub-Parts.js +27 -1
  51. package/snippets/Loading/TTFB.js +1 -1
  52. package/snippets/Loading/Third-Party-Impact-by-Domain.js +10 -2
  53. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +10 -2
  54. package/snippets/Loading/Webfont-Usage-Analyzer.js +105 -30
  55. package/snippets/Media/Image-Element-Audit.js +19 -2
  56. package/snippets/Media/Oversized-Images.js +28 -3
  57. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +48 -9
  58. package/snippets/Media/Video-Element-Audit.js +16 -10
  59. package/snippets/README.md +1 -1
  60. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +1 -1
  61. package/snippets/SCHEMA.md +33 -14
  62. 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.nucliweb.net) headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes and gate CI on real performance budgets.
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.nucliweb.net/CLI)):
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.nucliweb.net/CLI)):
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.4.1" }
137
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.0.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.nucliweb.net/CLI). Each snippet runs once, and a follow-up informs: its `error` issues do not change the exit code.
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.4.1",
3
+ "version": "1.0.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": "^2.0.0"
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.nucliweb.net",
45
+ "homepage": "https://webperf-snippets.net",
46
46
  "peerDependencies": {
47
47
  "playwright": ">=1.40.0"
48
48
  },
@@ -1,5 +1,5 @@
1
1
  // CLS Quick Check
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
5
  if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
@@ -1,5 +1,5 @@
1
1
  // INP (Interaction to Next Paint) Tracking
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
@@ -1,5 +1,5 @@
1
1
  // LCP Image Entropy Check
2
- // https://webperf-snippets.nucliweb.net
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.nucliweb.net
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(` ${selector}`, el);
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
- const el = lcpEntry.element;
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.nucliweb.net
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
- const getSelector = (element) => {
34
- if (element.id) return `#${element.id}`;
35
- if (element.className && typeof element.className === "string") {
36
- const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
37
- if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
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 element.tagName.toLowerCase();
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: getSelector(element),
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 = getSelector(el);
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.nucliweb.net
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 ? `<${element.tagName.toLowerCase()}>` : "(element no longer in DOM)";
73
- const rating = valueToRating(lcpTime);
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(` LCP time : ${lcpTime} ms ${RATING[rating].icon} ${RATING[rating].label}`);
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
- metric: "LCP",
85
- value: lcpTime,
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 considers both poster image and first video frame as LCP candidates.
100
- // lcpUrl is set when the LCP came from the poster; empty when from the first frame.
101
- const lcpSource = lcpUrl ? "poster" : posterAttr ? "unknown" : "first-frame";
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(lcpUrl || posterUrl);
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 — adding a poster gives explicit control over the LCP image" });
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 — browser discovers it late' });
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" — may be deprioritised' });
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} — AVIF or WebP would reduce file size and LCP load time` });
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 — resource is cross-origin and the server does not send Timing-Allow-Origin" });
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 : ${lcpUrl || posterUrl || "⚠️ (none)"}`);
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: lcpUrl || posterUrl || null,
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.nucliweb.net
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
- let selector = element.tagName.toLowerCase();
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.tagName.toLowerCase();
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,5 +1,5 @@
1
1
  // Fetch & XHR Timeline — Inject via DevTools Overrides
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
  (() => {
4
4
  const calls = [];
5
5
 
@@ -1,5 +1,5 @@
1
1
  // Fetch & XHR Timeline — Run in console after page load
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
5
  const calls = window.__perfCalls;
@@ -1,5 +1,5 @@
1
1
  // DOM Size and Depth
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  // Lighthouse "Avoid an excessive DOM size" thresholds
@@ -1,5 +1,5 @@
1
1
  // Forced Synchronous Layout Detector
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  let isDirty = false;
@@ -1,5 +1,5 @@
1
1
  // Input Latency Breakdown
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const formatMs = (ms) => `${Math.round(ms)}ms`;
@@ -1,5 +1,5 @@
1
1
  // Interaction Tracking
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const formatMs = (ms) => `${Math.round(ms)}ms`;
@@ -1,5 +1,5 @@
1
1
  // Layout Shift Tracking
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
5
  if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
@@ -1,5 +1,5 @@
1
1
  // LoAF Helpers - Advanced Debugging
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  "use strict";
@@ -1,5 +1,5 @@
1
1
  // Long Animation Frames - Script Attribution Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  // Check browser support
@@ -1,5 +1,5 @@
1
1
  // Long Animation Frames Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const formatMs = (ms) => `${Math.round(ms)}ms`;
@@ -1,5 +1,5 @@
1
1
  // Long Tasks Tracking
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
5
  if (!PerformanceObserver.supportedEntryTypes?.includes("longtask")) {
@@ -1,5 +1,5 @@
1
1
  // Scroll Performance Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const formatMs = (ms) => `${ms.toFixed(1)}ms`;
@@ -1,7 +1,24 @@
1
1
  // Back/Forward Cache (bfcache) Analysis
2
- // https://webperf-snippets.nucliweb.net
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({