webperf-snippets 0.2.0 → 0.4.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 (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
@@ -4,7 +4,8 @@
4
4
  (() => {
5
5
  new PerformanceObserver((entryList) => {
6
6
  const [pageNav] = entryList.getEntriesByType("navigation");
7
- const ttfb = pageNav.responseStart;
7
+ // Prerendered pages: measure from activation, not from the start of the prerender
8
+ const ttfb = Math.max(0, pageNav.responseStart - (pageNav.activationStart || 0));
8
9
 
9
10
  let rating, color;
10
11
  if (ttfb <= 800) {
@@ -30,7 +31,7 @@
30
31
  // Synchronous return for agent
31
32
  const [nav] = performance.getEntriesByType("navigation");
32
33
  if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
33
- const value = Math.round(nav.responseStart);
34
+ const value = Math.round(Math.max(0, nav.responseStart - (nav.activationStart || 0)));
34
35
  const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
35
36
  return { script: "TTFB", status: "ok", metric: "TTFB", value, unit: "ms", rating,
36
37
  thresholds: { good: 800, needsImprovement: 1800 } };
@@ -0,0 +1,244 @@
1
+ // Third-Party Impact by Domain
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
4
+
5
+ (() => {
6
+ const MAX_ITEMS = 50;
7
+ const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
8
+ const MAX_LISTED_DOMAINS = 5;
9
+
10
+ // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
11
+ // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
12
+ const OWN_DOMAINS = [];
13
+
14
+ // @shared formatBytes
15
+ function formatBytes(bytes) {
16
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
17
+ if (bytes === 0) return "0 B";
18
+ const units = ["B", "KB", "MB", "GB"];
19
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
20
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
21
+ }
22
+ // @end-shared formatBytes
23
+
24
+ // A size that misses some resources is a lower bound, and one that misses all of them is unknown
25
+ const sizeLabel = (bytes, unknown, total) => {
26
+ if (total > 0 && unknown === total) return "unknown";
27
+ return (unknown > 0 ? "≥ " : "") + formatBytes(bytes);
28
+ };
29
+
30
+ // @shared getRootDomain
31
+ function getRootDomain(hostname) {
32
+ const host = hostname.replace(/\.$/, "");
33
+ // An IP address has no registrable domain, so each address is its own root
34
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
35
+ const parts = host.split(".");
36
+ if (parts.length <= 2) return host;
37
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
38
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
39
+ const tld = parts[parts.length - 1];
40
+ const sld = parts[parts.length - 2];
41
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
42
+ return parts.slice(-2).join(".");
43
+ }
44
+ // @end-shared getRootDomain
45
+
46
+ // @shared isFirstParty
47
+ function isFirstParty(hostname) {
48
+ const root = getRootDomain(hostname);
49
+ if (root === getRootDomain(location.hostname)) return true;
50
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
51
+ }
52
+ // @end-shared isFirstParty
53
+
54
+ // @shared logOwnDomainsHint
55
+ function logOwnDomainsHint(thirdPartyCount) {
56
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
57
+ console.log(
58
+ '%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
59
+ "color: #3b82f6;"
60
+ );
61
+ }
62
+ }
63
+ // @end-shared logOwnDomainsHint
64
+
65
+ // Root domain of an http(s) URL, or null for anything else (data:, blob:, empty)
66
+ const rootDomainOf = (url) => {
67
+ try {
68
+ const u = new URL(url);
69
+ return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname) } : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ };
74
+
75
+ const domains = new Map();
76
+ const domainEntry = (root) => {
77
+ if (!domains.has(root)) {
78
+ domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: false, loafMs: null, loafScripts: 0 });
79
+ }
80
+ return domains.get(root);
81
+ };
82
+
83
+ let firstPartyRequests = 0;
84
+ let thirdPartyRequests = 0;
85
+ let thirdPartyBytes = 0;
86
+ let sizeUnknownCount = 0;
87
+
88
+ performance.getEntriesByType("resource").forEach((entry) => {
89
+ const parsed = rootDomainOf(entry.name);
90
+ if (!parsed) return;
91
+ if (parsed.first) {
92
+ firstPartyRequests++;
93
+ return;
94
+ }
95
+ const d = domainEntry(parsed.root);
96
+ d.requests++;
97
+ thirdPartyRequests++;
98
+
99
+ // Cross-origin resources without Timing-Allow-Origin report every size as zero
100
+ const sizeKnown = entry.transferSize > 0 || entry.encodedBodySize > 0 || entry.decodedBodySize > 0;
101
+ if (sizeKnown) {
102
+ const bytes = entry.transferSize || entry.encodedBodySize || 0;
103
+ d.transferBytes += bytes;
104
+ thirdPartyBytes += bytes;
105
+ } else {
106
+ d.sizeUnknownCount++;
107
+ sizeUnknownCount++;
108
+ }
109
+
110
+ if (entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
111
+ d.renderBlocking = true;
112
+ }
113
+ });
114
+
115
+ // Main-thread time: script time inside long animation frames, attributed by the script's source URL
116
+ const loafSupported = !!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame");
117
+ let thirdPartyLoafMs = null;
118
+ let longFrames = null;
119
+ let longFramesMs = null;
120
+ if (loafSupported) {
121
+ thirdPartyLoafMs = 0;
122
+ domains.forEach((d) => {
123
+ d.loafMs = 0;
124
+ });
125
+ const frames = performance.getEntriesByType("long-animation-frame");
126
+ longFrames = frames.length;
127
+ longFramesMs = frames.reduce((total, frame) => total + frame.duration, 0);
128
+ frames.forEach((frame) => {
129
+ (frame.scripts || []).forEach((script) => {
130
+ const parsed = rootDomainOf(script.sourceURL);
131
+ if (!parsed || parsed.first) return;
132
+ const d = domainEntry(parsed.root);
133
+ d.loafMs = (d.loafMs || 0) + script.duration;
134
+ d.loafScripts++;
135
+ thirdPartyLoafMs += script.duration;
136
+ });
137
+ });
138
+ domains.forEach((d) => {
139
+ d.loafMs = Math.round(d.loafMs || 0);
140
+ });
141
+ thirdPartyLoafMs = Math.round(thirdPartyLoafMs);
142
+ longFramesMs = Math.round(longFramesMs);
143
+ }
144
+ // Time in long frames that no third-party script accounts for: first-party scripts, scripts under
145
+ // the 5 ms that Long Animation Frames list, and style, layout and rendering work
146
+ const unattributedLoafMs = loafSupported ? Math.max(0, longFramesMs - thirdPartyLoafMs) : null;
147
+ const loafUnattributed = loafSupported && domains.size > 0 && thirdPartyLoafMs === 0 && longFrames > 0;
148
+
149
+ // Render-blocking first, then main-thread time, then transfer size
150
+ const all = [...domains.values()].sort(
151
+ (a, b) =>
152
+ Number(b.renderBlocking) - Number(a.renderBlocking) ||
153
+ (b.loafMs || 0) - (a.loafMs || 0) ||
154
+ b.transferBytes - a.transferBytes ||
155
+ a.domain.localeCompare(b.domain)
156
+ );
157
+
158
+ const blocking = all.filter((d) => d.renderBlocking);
159
+ const heavy = all.filter((d) => (d.loafMs || 0) > LOAF_WARNING_MS);
160
+ const corsLimitedAnalysis = sizeUnknownCount > 0;
161
+
162
+ const listDomains = (list) => {
163
+ const names = list.slice(0, MAX_LISTED_DOMAINS).map((d) => d.domain);
164
+ return names.join(", ") + (list.length > MAX_LISTED_DOMAINS ? ` and ${list.length - MAX_LISTED_DOMAINS} more` : "");
165
+ };
166
+
167
+ const issues = [];
168
+ if (blocking.length > 0) {
169
+ issues.push({
170
+ severity: "warning",
171
+ message: `${blocking.length} third-party domain(s) serve render-blocking resources (${listDomains(blocking)}); load them async or defer, or self-host them`,
172
+ });
173
+ }
174
+ heavy.slice(0, MAX_LISTED_DOMAINS).forEach((d) => {
175
+ issues.push({
176
+ severity: "warning",
177
+ message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
178
+ });
179
+ });
180
+ if (loafUnattributed) {
181
+ issues.push({
182
+ severity: "info",
183
+ message: `${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
184
+ });
185
+ }
186
+ if (corsLimitedAnalysis) {
187
+ issues.push({
188
+ severity: "info",
189
+ message: `${sizeUnknownCount} third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound`,
190
+ });
191
+ }
192
+
193
+ console.group("%c🌐 Third-Party Impact by Domain", "font-weight: bold; font-size: 14px;");
194
+ console.log(
195
+ `Third-party domains: ${all.length} | requests: ${thirdPartyRequests} | transfer: ${sizeLabel(thirdPartyBytes, sizeUnknownCount, thirdPartyRequests)} | first-party requests: ${firstPartyRequests}`
196
+ );
197
+ if (all.length > 0) {
198
+ console.table(
199
+ all.slice(0, MAX_ITEMS).map((d) => ({
200
+ Domain: d.domain,
201
+ Requests: d.requests,
202
+ Size: sizeLabel(d.transferBytes, d.sizeUnknownCount, d.requests),
203
+ "Render-blocking": d.renderBlocking ? "yes" : "no",
204
+ "Main thread": d.loafMs === null ? "n/a" : d.loafMs + " ms",
205
+ }))
206
+ );
207
+ } else {
208
+ console.log("%c✅ No third-party requests found", "color: #22c55e;");
209
+ }
210
+ if (!loafSupported) {
211
+ console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
212
+ }
213
+ if (loafUnattributed) {
214
+ console.log(
215
+ `%cℹ️ ${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
216
+ "color: #3b82f6;"
217
+ );
218
+ }
219
+ if (corsLimitedAnalysis) {
220
+ console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
221
+ }
222
+ logOwnDomainsHint(all.length);
223
+ console.groupEnd();
224
+
225
+ return {
226
+ script: "Third-Party-Impact-by-Domain",
227
+ status: "ok",
228
+ count: all.length,
229
+ corsLimitedAnalysis,
230
+ details: {
231
+ thirdPartyRequests,
232
+ thirdPartyBytes,
233
+ thirdPartyLoafMs,
234
+ longFrames,
235
+ unattributedLoafMs,
236
+ firstPartyRequests,
237
+ blockingDomains: blocking.length,
238
+ loafSupported,
239
+ sizeUnknownCount,
240
+ },
241
+ items: all.slice(0, MAX_ITEMS),
242
+ issues,
243
+ };
244
+ })();
@@ -378,6 +378,23 @@
378
378
 
379
379
  console.groupEnd();
380
380
 
381
+ // One item per script (an issue replaces the valid entry of the same URL), at most 50
382
+ const itemsByUrl = new Map();
383
+ validPreloads.forEach((v) => itemsByUrl.set(v.url, {
384
+ url: v.shortUrl,
385
+ type: v.type === "dynamic" ? "dynamic" : v.type === "mitigated" ? "mitigated" : "blocking",
386
+ strategy: v.attributes || "blocking-script",
387
+ location: v.location || "unknown",
388
+ reviewNote: v.reviewNote || v.note || "",
389
+ }));
390
+ issues.forEach((i) => itemsByUrl.set(i.url, {
391
+ url: i.shortUrl,
392
+ type: "issue",
393
+ strategy: i.attributes || i.type,
394
+ location: i.location || "unknown",
395
+ reviewNote: i.fix || "",
396
+ }));
397
+
381
398
  return {
382
399
  script: "Validate-Preload-Async-Defer-Scripts",
383
400
  status: "ok",
@@ -386,9 +403,10 @@
386
403
  validCount: validPreloads.length,
387
404
  issueCount: issues.length,
388
405
  },
389
- items: preloadedScripts.map(p => ({
390
- url: p.href.split("/").pop()?.split("?")[0] || p.href,
406
+ items: Array.from(itemsByUrl.values()).slice(0, 50),
407
+ issues: issues.map(i => ({
408
+ severity: i.severity,
409
+ message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
391
410
  })),
392
- issues: issues.map(i => ({ severity: i.severity, message: i.message })),
393
411
  };
394
412
  })();
@@ -19,6 +19,7 @@ When TTFB is slow or the user asks "why is my server slow":
19
19
  2. **TTFB-Sub-Parts.js** - Break down into DNS, connection, server time
20
20
  3. **Service-Worker-Analysis.js** - Check for SW overhead impacting TTFB
21
21
  4. **TTFB-Resources.js** - Identify slow third-party or API endpoints
22
+ 5. **Server-Timing-Early-Hints.js** - Read backend phases from `Server-Timing` and check 103 Early Hints
22
23
 
23
24
  ### Font Loading Optimization
24
25
 
@@ -27,6 +28,15 @@ When the user asks about fonts, FOIT, FOUT, or font performance:
27
28
  1. **Fonts-Preloaded-Loaded-and-used-above-the-fold.js** - Full font audit
28
29
  2. **Resource-Hints-Validation.js** - Verify font preloads are correct
29
30
  3. **Find-render-blocking-resources.js** - Check if fonts block rendering
31
+ 4. **Webfont-Usage-Analyzer.js** - Find font faces that are unused, never loaded, or use a blocking `font-display`
32
+
33
+ ### Text Compression Audit
34
+
35
+ When the user asks about compression, gzip, brotli, or large transfer sizes for text resources:
36
+
37
+ 1. **Compression-Audit.js** - Find text resources served without compression and estimate savings
38
+ 2. **Find-render-blocking-resources.js** - Prioritize uncompressed CSS/JS that block rendering
39
+ 3. **TTFB-Resources.js** - Check server response of the uncompressed resources
30
40
 
31
41
  ### Script Performance Deep Dive
32
42
 
@@ -34,6 +44,7 @@ When scripts are suspected to slow down the page:
34
44
 
35
45
  1. **Script-Loading.js** - Identify blocking scripts and loading strategy
36
46
  2. **First-And-Third-Party-Script-Info.js** - Separate first vs third-party impact
47
+ - **Third-Party-Impact-by-Domain.js** - Rank third-party domains by render-blocking, main-thread time and size
37
48
  3. **First-And-Third-Party-Script-Timings.js** - Diagnose slow script connections
38
49
  4. **JS-Execution-Time-Breakdown.js** - Network vs parse/execution time
39
50
  5. **Inline-Script-Info-and-Size.js** - Measure inline script overhead
@@ -48,6 +59,7 @@ When the user wants to optimize resource loading priorities:
48
59
  3. **Priority-Hints-Audit.js** - Check fetchpriority usage
49
60
  4. **Prefetch-Resource-Validation.js** - Validate prefetch strategy
50
61
  5. **Validate-Preload-Async-Defer-Scripts.js** - Find conflicts
62
+ 6. **Speculation-Rules-Inspector.js** - Inspect prefetch/prerender rules and prerender activation
51
63
 
52
64
  ### CSS Optimization Workflow
53
65
 
@@ -76,6 +88,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
76
88
  - **If TTFB > 600ms** → Run **TTFB-Sub-Parts.js** to diagnose where time is spent
77
89
  - **If Service Worker detected** → Run **Service-Worker-Analysis.js** to check for SW overhead
78
90
  - **If TTFB varies significantly across resources** → Run **TTFB-Resources.js**
91
+ - **If server time dominates TTFB-Sub-Parts.js** → Run **Server-Timing-Early-Hints.js** to see which backend phase is slow
92
+
93
+ ### After Server-Timing-Early-Hints.js
94
+
95
+ - **If no `Server-Timing` metrics found** → Recommend adding `Server-Timing` headers for database, cache and render phases
96
+ - **If cross-origin resources look hidden** → Recommend `Timing-Allow-Origin` on those responses
97
+ - **If no 103 Early Hints received and server time is high** → Recommend Early Hints for the main stylesheet and LCP image
79
98
 
80
99
  ### After FCP.js
81
100
 
@@ -109,6 +128,19 @@ Use this decision tree to automatically run follow-up snippets based on results:
109
128
  2. **Priority-Hints-Audit.js** (LCP candidate)
110
129
  - **If preloads on async/defer scripts** → Run **Validate-Preload-Async-Defer-Scripts.js**
111
130
 
131
+ ### After Compression-Audit.js
132
+
133
+ - **If uncompressed CSS/JS found** → Run **Find-render-blocking-resources.js** to see if they block rendering
134
+ - **If estimated savings > 100KB** → Recommend enabling gzip or brotli on the server or CDN
135
+ - **If corsLimitedAnalysis is true** → Report the result as a lower bound and suggest `Timing-Allow-Origin` on resources you control
136
+
137
+ ### After Third-Party-Impact-by-Domain.js
138
+
139
+ - **If a domain is render-blocking** → Recommend `async`, `defer`, lazy loading or self-hosting; run **Script-Loading.js** for the loading strategy
140
+ - **If a domain's main-thread time > 250ms** → Run **Long-Animation-Frames-Script-Attribution.js** (webperf-interaction) to find the functions responsible, and recommend delaying or removing the script
141
+ - **If many domains with few requests each** → Recommend auditing tags and removing unused ones
142
+ - **If corsLimitedAnalysis is true** → Report sizes as a lower bound; use **First-And-Third-Party-Script-Timings.js** for timings of the hidden resources
143
+
112
144
  ### After Service-Worker-Analysis.js
113
145
 
114
146
  - **If SW overhead > 100ms** → Recommend Navigation Preload API
@@ -119,7 +151,14 @@ Use this decision tree to automatically run follow-up snippets based on results:
119
151
 
120
152
  - **If fonts preloaded but not used above-the-fold** → Recommend removing preloads
121
153
  - **If fonts used but not preloaded** → Recommend adding preload
122
- - **If many font variants loaded** → Suggest subsetting or reducing variants
154
+ - **If many font variants loaded** → Suggest subsetting or reducing variants, and run **Webfont-Usage-Analyzer.js** to find the unused ones
155
+
156
+ ### After Webfont-Usage-Analyzer.js
157
+
158
+ - **If fonts loaded but not used** → Recommend removing the `@font-face` declaration or the preload
159
+ - **If faces use `font-display: auto` or `block`** → Recommend `swap` or `optional`, then check **Find-render-blocking-resources.js**
160
+ - **If font bytes are high** → Suggest subsetting with `unicode-range` and WOFF2
161
+ - **If the LCP element is text** → Run **LCP.js** with the **webperf-core-web-vitals** skill
123
162
 
124
163
  ### After First-And-Third-Party-Script-Info.js
125
164
 
@@ -145,6 +184,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
145
184
  - **If >10 prefetch hints** → Recommend reducing to critical resources
146
185
  - **If individual prefetch > 500KB** → Question necessity
147
186
  - **If total prefetch > 2MB** → Flag as mobile bandwidth concern
187
+ - **If few or no prefetch hints for likely next navigations** → Run **Speculation-Rules-Inspector.js** to check for prefetch/prerender rules
188
+
189
+ ### After Speculation-Rules-Inspector.js
190
+
191
+ - **If no speculation rules found but the site has predictable navigations** → Recommend adding prefetch or prerender rules
192
+ - **If invalid JSON or wasteful rules (eager prerender of many URLs)** → Recommend fixing or lowering eagerness to `moderate` or `conservative`
193
+ - **If the page was prerendered (`activationStart` > 0)** → Subtract `activationStart` when interpreting **TTFB.js**, **FCP.js** and LCP results
148
194
 
149
195
  ### After Critical-CSS-Detection.js
150
196