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.
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
@@ -1,7 +1,15 @@
1
1
  // Script Loading Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
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
  // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
6
14
  // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
7
15
  const OWN_DOMAINS = [];
@@ -64,7 +72,7 @@
64
72
 
65
73
  // Get resource timing data
66
74
  const resourceTiming = new Map();
67
- performance.getEntriesByType("resource").forEach((r) => {
75
+ performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e)).forEach((r) => {
68
76
  if (r.initiatorType === "script") {
69
77
  resourceTiming.set(r.name, {
70
78
  size: r.transferSize || 0,
@@ -1,7 +1,15 @@
1
1
  // Server-Timing & Early Hints Viewer
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
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
  const SCRIPT = "Server-Timing-Early-Hints";
6
14
  const MAX_ITEMS = 50;
7
15
 
@@ -38,7 +46,7 @@
38
46
  // counted as unreadable instead of being treated as a resource with no metrics.
39
47
  let resourcesWithServerTiming = 0;
40
48
  let corsRestrictedCount = 0;
41
- for (const r of performance.getEntriesByType("resource")) {
49
+ for (const r of performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))) {
42
50
  if (r.serverTiming.length > 0) {
43
51
  resourcesWithServerTiming++;
44
52
  for (const m of r.serverTiming) all.push(toItem(m, "resource", r.name));
@@ -1,7 +1,15 @@
1
1
  // Service Worker 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 (!('serviceWorker' in navigator)) {
6
14
  console.log(
7
15
  '%c⚠️ Service Workers not supported in this browser',
@@ -21,7 +29,7 @@
21
29
  const registrations = await navigator.serviceWorker.getRegistrations();
22
30
  const controller = navigator.serviceWorker.controller;
23
31
  const navEntry = performance.getEntriesByType('navigation')[0];
24
- const resources = performance.getEntriesByType('resource');
32
+ const resources = performance.getEntriesByType('resource').filter((e) => !isOwnRequest(e));
25
33
 
26
34
  // Resources intercepted by SW (workerStart > 0)
27
35
  const swResources = resources.filter((r) => r.workerStart > 0);
@@ -1,5 +1,5 @@
1
1
  // Prerender & Speculation Rules Inspector
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const MAX_ITEMS = 50;
@@ -1,7 +1,15 @@
1
1
  // Measure TTFB for all resources with sorting and summary
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
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
  // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
6
14
  // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
7
15
  const OWN_DOMAINS = [];
@@ -55,7 +63,7 @@
55
63
  };
56
64
 
57
65
  new PerformanceObserver((entryList) => {
58
- const entries = entryList.getEntries();
66
+ const entries = entryList.getEntries().filter((e) => !isOwnRequest(e));
59
67
  const restrictedCount = entries.filter((entry) => !hasTiming(entry)).length;
60
68
 
61
69
  const resourcesData = entries
@@ -138,7 +146,7 @@
138
146
  });
139
147
 
140
148
  // Synchronous return for agent
141
- const allResources = performance.getEntriesByType("resource");
149
+ const allResources = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e));
142
150
  const corsRestrictedCount = allResources.filter((entry) => !hasTiming(entry)).length;
143
151
  const resourcesSync = allResources
144
152
  .filter(hasTiming)
@@ -1,5 +1,5 @@
1
1
  // Measure TTFB sub-parts breakdown
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  new PerformanceObserver((entryList) => {
@@ -108,6 +108,31 @@
108
108
  { key: "serverResponse", duration: ttfbValueSync - tcpEndSync },
109
109
  ];
110
110
  const slowestSync = subPartsSync.reduce((a, b) => a.duration > b.duration ? a : b);
111
+ const phaseLabels = {
112
+ redirectWait: "redirect and wait",
113
+ serviceWorkerCache: "service worker and cache",
114
+ dnsLookup: "DNS lookup",
115
+ tcpConnection: "TCP connection",
116
+ sslTls: "SSL/TLS",
117
+ serverResponse: "server response",
118
+ };
119
+ const phaseFixes = {
120
+ redirectWait: "minimize redirects and link to the final URL",
121
+ serviceWorkerCache: "review the service worker startup and consider a cache-first strategy",
122
+ dnsLookup: "use dns-prefetch or a faster DNS provider",
123
+ tcpConnection: "enable HTTP/2 or HTTP/3 and keep connections alive",
124
+ sslTls: "enable TLS 1.3 and session resumption, and check the certificate chain",
125
+ serverResponse: "optimize server processing, cache the HTML and check slow queries",
126
+ };
127
+ const issues = [];
128
+ if (rating !== "good") {
129
+ const slowestMs = Math.round(slowestSync.duration);
130
+ const share = ttfbValueSync > 0 ? Math.round((slowestSync.duration / ttfbValueSync) * 100) : 0;
131
+ issues.push({
132
+ severity: rating === "poor" ? "error" : "warning",
133
+ message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.`,
134
+ });
135
+ }
111
136
  return {
112
137
  script: "TTFB-Sub-Parts",
113
138
  status: "ok",
@@ -120,5 +145,6 @@
120
145
  subParts: Object.fromEntries(subPartsSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
121
146
  slowestPhase: slowestSync.key,
122
147
  },
148
+ issues,
123
149
  };
124
150
  })();
@@ -1,5 +1,5 @@
1
1
  // Measure TTFB with threshold indicator
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  new PerformanceObserver((entryList) => {
@@ -1,8 +1,16 @@
1
1
  // Third-Party Impact by Domain
2
2
  // Groups third-party requests by root domain and reports requests, transfer size, render-blocking and main-thread time
3
- // https://webperf-snippets.nucliweb.net
3
+ // https://webperf-snippets.net
4
4
 
5
5
  (() => {
6
+ // @shared isOwnRequest
7
+ // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
8
+ // did not make it, so no snippet counts it.
9
+ function isOwnRequest(entry) {
10
+ return entry.name.endsWith("#webperf-snippets");
11
+ }
12
+ // @end-shared isOwnRequest
13
+
6
14
  const MAX_ITEMS = 50;
7
15
  const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
8
16
  const MAX_LISTED_DOMAINS = 5;
@@ -85,7 +93,7 @@
85
93
  let thirdPartyBytes = 0;
86
94
  let sizeUnknownCount = 0;
87
95
 
88
- performance.getEntriesByType("resource").forEach((entry) => {
96
+ performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e)).forEach((entry) => {
89
97
  const parsed = rootDomainOf(entry.name);
90
98
  if (!parsed) return;
91
99
  if (parsed.first) {
@@ -1,7 +1,15 @@
1
1
  // Validate Preload on Async/Defer Scripts
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
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
  // Get all preloaded scripts
6
14
  const preloadedScripts = Array.from(
7
15
  document.querySelectorAll('link[rel="preload"][as="script"]'),
@@ -21,7 +29,7 @@
21
29
 
22
30
  // Get performance entries for scripts
23
31
  const performanceScripts = performance
24
- .getEntriesByType("resource")
32
+ .getEntriesByType("resource").filter((e) => !isOwnRequest(e))
25
33
  .filter((r) => r.initiatorType === "script" || r.initiatorType === "link");
26
34
 
27
35
  // Find corresponding script elements for each preload
@@ -1,5 +1,5 @@
1
1
  // Webfont Usage Analyzer
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
  //
4
4
  // Cross-checks every @font-face known to document.fonts against the text that renders on the
5
5
  // page: faces loaded but not used, faces declared but never loaded, unicode-range subsets,
@@ -7,6 +7,14 @@
7
7
  // Idea and original script: https://github.com/paulcalvano/webfont-usage-analyzer
8
8
 
9
9
  (() => {
10
+ // @shared isOwnRequest
11
+ // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
12
+ // did not make it, so no snippet counts it.
13
+ function isOwnRequest(entry) {
14
+ return entry.name.endsWith("#webperf-snippets");
15
+ }
16
+ // @end-shared isOwnRequest
17
+
10
18
  const SCRIPT = "Webfont-Usage-Analyzer";
11
19
  const MAX_ITEMS = 50;
12
20
  const MAX_ELEMENTS = 20000;
@@ -60,7 +68,9 @@
60
68
  // --- Font file URLs, from the @font-face rules ---------------------------------------
61
69
 
62
70
  let unreadableStylesheets = 0;
63
- const ruleSources = new Map(); // "family|weight|style|range" -> [absolute urls]
71
+ // Two stylesheets can declare the same face with different files, so each rule keeps its own list,
72
+ // in document order, which is also the order of document.fonts
73
+ const ruleSources = new Map(); // "family|weight|style|range" -> [[absolute urls of each rule]]
64
74
  const faceKey = (family, weight, style, range) =>
65
75
  [stripQuotes(family).toLowerCase(), String(weight).trim() || "normal", String(style).trim() || "normal", isDefaultRange(range) ? "" : parseRanges(range).map((r) => r.join("-")).join(",")].join("|");
66
76
 
@@ -79,7 +89,7 @@
79
89
  })
80
90
  .filter((u) => u && !u.startsWith("data:"));
81
91
  const key = faceKey(s.getPropertyValue("font-family"), s.getPropertyValue("font-weight"), s.getPropertyValue("font-style"), s.getPropertyValue("unicode-range"));
82
- ruleSources.set(key, [...(ruleSources.get(key) ?? []), ...urls]);
92
+ ruleSources.set(key, [...(ruleSources.get(key) ?? []), urls]);
83
93
  } else if (rule.styleSheet) {
84
94
  readSheet(rule.styleSheet, depth + 1);
85
95
  } else if (rule.cssRules) {
@@ -100,9 +110,9 @@
100
110
 
101
111
  // --- Bytes, from Resource Timing -----------------------------------------------------
102
112
 
103
- const knownUrls = new Set([...ruleSources.values()].flat());
113
+ const knownUrls = new Set([...ruleSources.values()].flat(2));
104
114
  const fontEntries = new Map(); // url -> encodedBodySize
105
- for (const e of performance.getEntriesByType("resource")) {
115
+ for (const e of performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))) {
106
116
  if (FONT_FILE.test(e.name) || knownUrls.has(e.name)) {
107
117
  fontEntries.set(e.name, Math.max(fontEntries.get(e.name) ?? 0, e.encodedBodySize || 0));
108
118
  }
@@ -158,10 +168,14 @@
158
168
 
159
169
  // --- Faces ---------------------------------------------------------------------------
160
170
 
171
+ const seenKeys = new Map(); // key -> faces seen so far, to pair the nth face with the nth rule
161
172
  const faces = Array.from(document.fonts).map((f) => {
162
173
  const family = stripQuotes(f.family);
163
174
  const range = isDefaultRange(f.unicodeRange) ? "" : f.unicodeRange.trim();
164
- const urls = ruleSources.get(faceKey(f.family, f.weight, f.style, f.unicodeRange)) ?? [];
175
+ const key = faceKey(f.family, f.weight, f.style, f.unicodeRange);
176
+ const nth = seenKeys.get(key) ?? 0;
177
+ seenKeys.set(key, nth + 1);
178
+ const urls = ruleSources.get(key)?.[nth] ?? [];
165
179
  const url = urls.find((u) => fontEntries.has(u)) ?? urls[0] ?? "";
166
180
  return {
167
181
  family,
@@ -171,8 +185,11 @@
171
185
  unicodeRange: range,
172
186
  status: f.status,
173
187
  used: false,
174
- bytes: url && fontEntries.has(url) ? fontEntries.get(url) : 0,
188
+ // A face that did not load cost nothing, even when another declaration fetched the same file
189
+ bytes: f.status === "loaded" && fontEntries.has(url) ? fontEntries.get(url) : 0,
175
190
  url,
191
+ _key: key,
192
+ _redeclared: false,
176
193
  _weightRange: parseWeight(f.weight),
177
194
  _ranges: range ? parseRanges(range) : null,
178
195
  };
@@ -197,6 +214,21 @@
197
214
  }
198
215
  }
199
216
 
217
+ // When several rules declare the same face, the browser renders with the later one that loaded;
218
+ // the others are not used, whether they loaded or not. When none loaded, they are all faces the
219
+ // page did not need
220
+ const byKey = new Map();
221
+ faces.forEach((f) => byKey.set(f._key, [...(byKey.get(f._key) ?? []), f]));
222
+ for (const group of byKey.values()) {
223
+ if (group.length < 2) continue;
224
+ const active = group.findLast((f) => f.status === "loaded");
225
+ if (!active) continue;
226
+ group.filter((f) => f !== active).forEach((f) => {
227
+ f.used = false;
228
+ f._redeclared = true;
229
+ });
230
+ }
231
+
200
232
  // --- Classification ------------------------------------------------------------------
201
233
 
202
234
  // Split fonts declare long ranges; keep reported text short
@@ -206,10 +238,15 @@
206
238
  const loadedUnused = loaded.filter((f) => !f.used);
207
239
  const neverLoaded = faces.filter((f) => f.status === "unloaded");
208
240
  const failed = faces.filter((f) => f.status === "error");
209
- const neverLoadedSubsets = neverLoaded.filter((f) => f.unicodeRange);
210
- const neverLoadedWhole = neverLoaded.filter((f) => !f.unicodeRange);
241
+ const shadowed = neverLoaded.filter((f) => f._redeclared);
242
+ const neverLoadedSubsets = neverLoaded.filter((f) => f.unicodeRange && !f._redeclared);
243
+ const neverLoadedWhole = neverLoaded.filter((f) => !f.unicodeRange && !f._redeclared);
211
244
  const blockingDisplay = faces.filter((f) => (f.status === "loaded" || f.status === "loading") && (f.display === "auto" || f.display === "block"));
212
- const loadedUnusedBytes = loadedUnused.reduce((sum, f) => sum + f.bytes, 0);
245
+ // A file counts once, and not at all when a used face renders with it
246
+ const usedUrls = new Set(loaded.filter((f) => f.used && f.url).map((f) => f.url));
247
+ const wastedBytes = (f) => (f.url && usedUrls.has(f.url) ? 0 : f.bytes);
248
+ const wastedByFile = new Map(loadedUnused.map((f) => [f.url || label(f), wastedBytes(f)]));
249
+ const loadedUnusedBytes = [...wastedByFile.values()].reduce((sum, b) => sum + b, 0);
213
250
 
214
251
  const bySubsetFamily = new Map();
215
252
  for (const f of faces.filter((x) => x.unicodeRange)) {
@@ -223,34 +260,43 @@
223
260
  const displayCounts = {};
224
261
  faces.forEach((f) => (displayCounts[f.display] = (displayCounts[f.display] ?? 0) + 1));
225
262
 
263
+ // A stylesheet can declare the same face twice, so messages name and count each face once
264
+ const uniqueLabels = (list) => [...new Set(list.map(label))];
226
265
  const names = (list) => {
227
- // A stylesheet can declare the same face twice
228
- const unique = [...new Set(list.map(label))];
266
+ const unique = uniqueLabels(list);
229
267
  const shown = unique.slice(0, 8).join(", ");
230
268
  return unique.length > 8 ? `${shown} and ${unique.length - 8} more` : shown;
231
269
  };
232
270
 
233
271
  const issues = [
234
272
  ...failed.map((f) => ({ severity: "error", message: `Font failed to load: ${label(f)}` })),
235
- ...loadedUnused.map((f) => ({
236
- severity: "warning",
237
- message: `Loaded but not used by any rendered element: ${label(f)}${f.bytes ? `, ${kb(f.bytes)} downloaded for nothing` : ""}`,
238
- })),
273
+ ...[...new Map(loadedUnused.map((f) => [`${label(f)}|${f.url}`, f])).values()].map((f) => {
274
+ const bytes = wastedBytes(f) ? `, ${kb(wastedBytes(f))} downloaded for nothing` : "";
275
+ return {
276
+ severity: "warning",
277
+ message: f._redeclared
278
+ ? `Loaded but declared again later with the same descriptors, so the page renders with the other file: ${label(f)}${f.url ? ` from ${f.url}` : ""}${bytes}`
279
+ : `Loaded but not used by any rendered element: ${label(f)}${bytes}`,
280
+ };
281
+ }),
239
282
  ];
240
283
  if (blockingDisplay.length > 0) {
241
284
  issues.push({
242
285
  severity: "warning",
243
- message: `font-display is auto or block on ${blockingDisplay.length} face(s), so text can stay invisible while they load. Use swap or optional: ${names(blockingDisplay)}`,
286
+ message: `font-display is auto or block on ${uniqueLabels(blockingDisplay).length} face(s), so text can stay invisible while they load. Use swap or optional: ${names(blockingDisplay)}`,
244
287
  });
245
288
  }
246
289
  if (totalFontBytes > BYTES_WARNING) {
247
290
  issues.push({ severity: "warning", message: `Web fonts weigh ${kb(totalFontBytes)} in total (over ${kb(BYTES_WARNING)}). Subset, use WOFF2 or drop unused weights` });
248
291
  }
249
292
  if (neverLoadedWhole.length > 0) {
250
- issues.push({ severity: "info", message: `Declared but never loaded (${neverLoadedWhole.length}); nothing on this page needed them: ${names(neverLoadedWhole)}` });
293
+ issues.push({ severity: "info", message: `Declared but never loaded (${uniqueLabels(neverLoadedWhole).length}); nothing on this page needed them: ${names(neverLoadedWhole)}` });
294
+ }
295
+ if (shadowed.length > 0) {
296
+ issues.push({ severity: "info", message: `Declared more than once with the same descriptors; the page uses a later declaration, so these never loaded (${uniqueLabels(shadowed).length}): ${names(shadowed)}` });
251
297
  }
252
298
  if (neverLoadedSubsets.length > 0) {
253
- issues.push({ severity: "info", message: `unicode-range subsets this page did not need (${neverLoadedSubsets.length}), which is the intended behavior of a split font: ${names(neverLoadedSubsets)}` });
299
+ issues.push({ severity: "info", message: `unicode-range subsets this page did not need (${uniqueLabels(neverLoadedSubsets).length}), which is the intended behavior of a split font: ${names(neverLoadedSubsets)}` });
254
300
  }
255
301
  if (!bytesReadable) {
256
302
  issues.push({ severity: "info", message: "Some font files are cross-origin without Timing-Allow-Origin, so their size reads as 0 and the byte totals are a lower bound" });
@@ -262,14 +308,37 @@
262
308
  issues.push({ severity: "info", message: `Only the first ${MAX_ELEMENTS} elements were scanned for text; some faces may be reported as unused` });
263
309
  }
264
310
 
311
+ // --- One row per face and file --------------------------------------------------------
312
+
313
+ // Identical rules (same face, same file) are one row, with how many rules declare it. Rows are the
314
+ // faces that downloaded or tried to; the ones that never loaded cost nothing and are only named
315
+ const STATUS_ORDER = ["loaded", "loading", "error"];
316
+ const rowGroups = new Map();
317
+ faces.filter((f) => f.status !== "unloaded").forEach((f) => {
318
+ const key = `${f._key}|${f.url}`;
319
+ rowGroups.set(key, [...(rowGroups.get(key) ?? []), f]);
320
+ });
321
+ const note = (f) => {
322
+ if (f.used) return "";
323
+ if (f.status === "error") return "failed to load";
324
+ if (f.status === "loaded") return f._redeclared ? "declared again later" : "loaded but not used";
325
+ return "";
326
+ };
327
+ const rows = [...rowGroups.values()].map((group) => {
328
+ const f = group.find((x) => x.used) ?? [...group].sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status))[0];
329
+ return { ...f, declarations: group.length, note: note(f) };
330
+ });
331
+ const rowsLoaded = rows.filter((f) => f.status === "loaded");
332
+ const neverLoadedNames = uniqueLabels([...neverLoadedWhole, ...neverLoadedSubsets]);
333
+
265
334
  // --- Console output ------------------------------------------------------------------
266
335
 
267
336
  console.group("%cWebfont Usage Analyzer", "font-weight: bold; font-size: 14px;");
268
- console.log(`Faces: ${faces.length} | loaded: ${loaded.length} | used: ${faces.filter((f) => f.used).length} | loaded but unused: ${loadedUnused.length} | never loaded: ${neverLoaded.length}`);
337
+ console.log(`Rules: ${faces.length} | loaded: ${rowsLoaded.length} | used: ${rowsLoaded.filter((f) => f.used).length} | loaded but unused: ${rowsLoaded.filter((f) => !f.used).length} | never needed: ${neverLoadedNames.length}`);
269
338
  console.log(`Font files: ${fontEntries.size} | total: ${kb(totalFontBytes)}${bytesReadable ? "" : " (lower bound, cross-origin sizes hidden)"}`);
270
- if (faces.length > 0) {
339
+ if (rows.length > 0) {
271
340
  console.table(
272
- faces.map((f) => ({
341
+ rows.map((f) => ({
273
342
  Family: f.family,
274
343
  Weight: f.weight,
275
344
  Style: f.style,
@@ -277,11 +346,13 @@
277
346
  "unicode-range": shortRange(f.unicodeRange) || "all",
278
347
  Status: f.status,
279
348
  Used: f.used ? "yes" : "no",
349
+ Rules: f.declarations,
280
350
  Size: f.bytes ? kb(f.bytes) : "-",
351
+ Note: f.note,
281
352
  }))
282
353
  );
283
354
  } else {
284
- console.log("No @font-face rules or FontFace objects on this page.");
355
+ console.log(faces.length > 0 ? "No web font loaded on this page." : "No @font-face rules or FontFace objects on this page.");
285
356
  }
286
357
  issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
287
358
  console.groupEnd();
@@ -290,7 +361,7 @@
290
361
 
291
362
  // Problems first, so the 50-item cap never hides them
292
363
  const rank = (f) => (f.status === "error" ? 0 : f.status === "loaded" && !f.used ? 1 : f.status === "loaded" ? 2 : 3);
293
- const items = faces
364
+ const items = rows
294
365
  .map((f, index) => ({ f, index }))
295
366
  .sort((a, b) => rank(a.f) - rank(b.f) || a.index - b.index)
296
367
  .slice(0, MAX_ITEMS)
@@ -302,22 +373,26 @@
302
373
  unicodeRange: shortRange(f.unicodeRange),
303
374
  status: f.status,
304
375
  used: f.used,
376
+ declarations: f.declarations,
305
377
  bytes: f.bytes,
378
+ note: f.note,
306
379
  url: f.url,
307
380
  }));
308
381
 
309
382
  return {
310
383
  script: SCRIPT,
311
384
  status: "ok",
312
- count: faces.length,
385
+ count: rows.length,
313
386
  corsLimitedAnalysis: !bytesReadable,
314
387
  details: {
315
- loadedCount: loaded.length,
316
- usedCount: loaded.filter((f) => f.used).length,
317
- loadedUnusedCount: loadedUnused.length,
388
+ declarationCount: faces.length,
389
+ loadedCount: rowsLoaded.length,
390
+ usedCount: rowsLoaded.filter((f) => f.used).length,
391
+ loadedUnusedCount: rowsLoaded.filter((f) => !f.used).length,
318
392
  loadedUnusedBytes,
319
- neverLoadedCount: neverLoaded.length,
320
- errorCount: failed.length,
393
+ neverLoadedCount: neverLoadedNames.length,
394
+ neverLoaded: neverLoadedNames.slice(0, MAX_ITEMS),
395
+ errorCount: rows.filter((f) => f.status === "error").length,
321
396
  fontFileCount: fontEntries.size,
322
397
  totalFontBytes,
323
398
  bytesReadable,
@@ -1,7 +1,24 @@
1
1
  // Image Element Audit
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
  function isInViewport(el) {
6
23
  const rect = el.getBoundingClientRect();
7
24
  return (
@@ -102,7 +119,7 @@
102
119
  async function fetchFormat(url) {
103
120
  if (!url) return detectFormat(url);
104
121
  try {
105
- const res = await fetch(url, { cache: "force-cache" });
122
+ const res = await fetch(ownRequestUrl(url), { cache: "force-cache" });
106
123
  const ct = res.headers.get("content-type")?.split(";")[0]?.trim() || "";
107
124
  if (ct.includes("avif")) return "avif";
108
125
  if (ct.includes("webp")) return "webp";
@@ -1,7 +1,32 @@
1
1
  // Oversized Images
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
+
22
+ // @shared isOwnRequest
23
+ // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
24
+ // did not make it, so no snippet counts it.
25
+ function isOwnRequest(entry) {
26
+ return entry.name.endsWith("#webperf-snippets");
27
+ }
28
+ // @end-shared isOwnRequest
29
+
5
30
  const OVERSIZED_RATIO = 1.5; // file has at least 1.5x the pixels it needs on each axis
6
31
  const UNDERSIZED_RATIO = 0.9; // file has less than 90% of the pixels it needs
7
32
  const SEVERE_OVERSIZED = 2;
@@ -42,14 +67,14 @@
42
67
  clearTimeout(timer);
43
68
  resolve(null);
44
69
  };
45
- probe.src = url;
70
+ probe.src = ownRequestUrl(url);
46
71
  });
47
72
  }
48
73
 
49
74
  // Bytes of each image URL; null when the browser hides them (cross-origin without
50
75
  // Timing-Allow-Origin).
51
76
  const transferByUrl = new Map();
52
- performance.getEntriesByType("resource").forEach((e) => {
77
+ performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e)).forEach((e) => {
53
78
  if (e.initiatorType !== "img" && e.initiatorType !== "css" && e.initiatorType !== "other") return;
54
79
  const bytes = e.transferSize > 0 ? e.transferSize : e.encodedBodySize > 0 ? e.encodedBodySize : null;
55
80
  if (bytes !== null) transferByUrl.set(e.name, bytes);