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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// Script Loading Analysis
|
|
2
|
-
// https://webperf-snippets.
|
|
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.
|
|
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.
|
|
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,7 +1,15 @@
|
|
|
1
1
|
// Measure TTFB for all resources with sorting and summary
|
|
2
|
-
// https://webperf-snippets.
|
|
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.
|
|
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
|
})();
|
package/snippets/Loading/TTFB.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) ?? []),
|
|
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
|
|
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
|
-
|
|
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
|
|
210
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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(`
|
|
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 (
|
|
339
|
+
if (rows.length > 0) {
|
|
271
340
|
console.table(
|
|
272
|
-
|
|
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 =
|
|
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:
|
|
385
|
+
count: rows.length,
|
|
313
386
|
corsLimitedAnalysis: !bytesReadable,
|
|
314
387
|
details: {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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:
|
|
320
|
-
|
|
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.
|
|
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.
|
|
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);
|