webperf-snippets 0.2.0 → 0.3.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 (65) hide show
  1. package/README.md +120 -5
  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 +52 -30
  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 +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
  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 +55 -25
  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 +59 -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 +216 -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 +22 -3
  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 +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -54,13 +54,15 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
54
54
  }
55
55
 
56
56
  // Helper to format bytes
57
+ // @shared formatBytes
57
58
  function formatBytes(bytes) {
58
- if (bytes === 0) return "0 Bytes";
59
- const k = 1024;
60
- const sizes = ["Bytes", "KB", "MB"];
61
- const i = Math.floor(Math.log(bytes) / Math.log(k));
62
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
59
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
60
+ if (bytes === 0) return "0 B";
61
+ const units = ["B", "KB", "MB", "GB"];
62
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
63
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
63
64
  }
65
+ // @end-shared formatBytes
64
66
 
65
67
  // Parse CSS text to find @media rules
66
68
  function parseMediaQueriesFromCSS(cssText, source, isInline) {
@@ -254,8 +256,60 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
254
256
  // CSS Performance Impact Analyzer
255
257
  // Estimates the real-world performance cost of unnecessary CSS on mobile devices
256
258
 
259
+ // Device profiles based on real Chrome UX Report data. A function, not a top-level const, so
260
+ // the snippet can be run again in the same console.
261
+ function getDeviceProfiles() {
262
+ return {
263
+ "High-end (Pixel 7, iPhone 14)": {
264
+ cssParsingSpeed: 1.5, // MB/s
265
+ cssSelectorMatchingMultiplier: 1.0,
266
+ networkSpeed: 10, // Mbps (4G LTE)
267
+ description: "Top 25% devices",
268
+ },
269
+ "Mid-range (Moto G Power, iPhone SE)": {
270
+ cssParsingSpeed: 0.8, // MB/s
271
+ cssSelectorMatchingMultiplier: 1.8,
272
+ networkSpeed: 5, // Mbps (4G)
273
+ description: "Median mobile device",
274
+ },
275
+ "Low-end (Moto E, older devices)": {
276
+ cssParsingSpeed: 0.3, // MB/s
277
+ cssSelectorMatchingMultiplier: 3.5,
278
+ networkSpeed: 2, // Mbps (3G/slow 4G)
279
+ description: "Bottom 25% devices",
280
+ },
281
+ };
282
+ }
283
+
284
+ // Estimated cost of the desktop-only CSS on each device profile. No console output.
285
+ function estimateCSSImpact(summary) {
286
+ const deviceProfiles = getDeviceProfiles();
287
+ const unnecessaryBytes = summary.total.bytes;
288
+ const totalClasses = summary.total.classes;
289
+ const totalProperties = summary.total.properties;
290
+ const deviceImpact = Object.entries(deviceProfiles).reduce((acc, [name, profile]) => {
291
+ const download = (unnecessaryBytes * 8) / (profile.networkSpeed * 1000);
292
+ const parsing = (unnecessaryBytes / (1024 * 1024) / profile.cssParsingSpeed) * 1000;
293
+ const cssom = totalProperties * 0.01 * profile.cssSelectorMatchingMultiplier;
294
+ const totalBlocking = download + parsing + cssom;
295
+ const runtime =
296
+ (totalClasses * 0.005 + totalProperties * 0.002) * profile.cssSelectorMatchingMultiplier;
297
+
298
+ acc[name] = {
299
+ renderBlockingTimeMs: totalBlocking,
300
+ runtimeOverheadMs: runtime,
301
+ fcpImpactMs: totalBlocking * 0.6,
302
+ lcpImpactMs: totalBlocking * 0.4,
303
+ inpOverheadMs: runtime,
304
+ };
305
+ return acc;
306
+ }, {});
307
+ return { unnecessaryBytes, totalClasses, totalProperties, deviceImpact };
308
+ }
309
+
257
310
  async function analyzeCSSPerformanceImpact(minWidth = 768) {
258
311
  console.log("🔍 Analyzing CSS performance impact...\n");
312
+ const deviceProfiles = getDeviceProfiles();
259
313
 
260
314
  // First, run the media queries analysis
261
315
  const mediaQueryResults = await analyzeCSSMediaQueries(minWidth);
@@ -286,28 +340,6 @@ async function analyzeCSSPerformanceImpact(minWidth = 768) {
286
340
  return null;
287
341
  }
288
342
 
289
- // Device profiles based on real Chrome UX Report data
290
- const deviceProfiles = {
291
- "High-end (Pixel 7, iPhone 14)": {
292
- cssParsingSpeed: 1.5, // MB/s
293
- cssSelectorMatchingMultiplier: 1.0,
294
- networkSpeed: 10, // Mbps (4G LTE)
295
- description: "Top 25% devices",
296
- },
297
- "Mid-range (Moto G Power, iPhone SE)": {
298
- cssParsingSpeed: 0.8, // MB/s
299
- cssSelectorMatchingMultiplier: 1.8,
300
- networkSpeed: 5, // Mbps (4G)
301
- description: "Median mobile device",
302
- },
303
- "Low-end (Moto E, older devices)": {
304
- cssParsingSpeed: 0.3, // MB/s
305
- cssSelectorMatchingMultiplier: 3.5,
306
- networkSpeed: 2, // Mbps (3G/slow 4G)
307
- description: "Bottom 25% devices",
308
- },
309
- };
310
-
311
343
  const totalClasses = mediaQueryResults.summary.total.classes;
312
344
  const totalProperties = mediaQueryResults.summary.total.properties;
313
345
 
@@ -464,23 +496,7 @@ async function analyzeCSSPerformanceImpact(minWidth = 768) {
464
496
  totalClasses,
465
497
  totalProperties,
466
498
  corsBlockedCount: mediaQueryResults.summary.corsBlocked,
467
- deviceImpact: Object.entries(deviceProfiles).reduce((acc, [name, profile]) => {
468
- const download = (unnecessaryBytes * 8) / (profile.networkSpeed * 1000);
469
- const parsing = (unnecessaryBytes / (1024 * 1024) / profile.cssParsingSpeed) * 1000;
470
- const cssom = totalProperties * 0.01 * profile.cssSelectorMatchingMultiplier;
471
- const totalBlocking = download + parsing + cssom;
472
- const runtime =
473
- (totalClasses * 0.005 + totalProperties * 0.002) * profile.cssSelectorMatchingMultiplier;
474
-
475
- acc[name] = {
476
- renderBlockingTimeMs: totalBlocking,
477
- runtimeOverheadMs: runtime,
478
- fcpImpactMs: totalBlocking * 0.6,
479
- lcpImpactMs: totalBlocking * 0.4,
480
- inpOverheadMs: runtime,
481
- };
482
- return acc;
483
- }, {}),
499
+ deviceImpact: estimateCSSImpact(mediaQueryResults.summary).deviceImpact,
484
500
  estimatedConversionLift: ((midRangeTotalBlocking * 0.6) / 100).toFixed(2) + "%",
485
501
  };
486
502
  }
@@ -502,6 +518,22 @@ window.analyzeCSSPerformanceImpact = analyzeCSSPerformanceImpact;
502
518
  inline: result.summary.inline,
503
519
  files: result.summary.files,
504
520
  corsBlockedCount: result.summary.corsBlocked,
521
+ // Estimated cost of the desktop-only CSS, computed silently (the console report stays
522
+ // opt-in through analyzeCSSPerformanceImpact()). null when there is none.
523
+ performanceImpact: (() => {
524
+ const impact = estimateCSSImpact(result.summary);
525
+ if (impact.unnecessaryBytes === 0) return null;
526
+ const round1 = (n) => Math.round(n * 10) / 10;
527
+ return {
528
+ ...impact,
529
+ deviceImpact: Object.fromEntries(
530
+ Object.entries(impact.deviceImpact).map(([device, d]) => [
531
+ device,
532
+ Object.fromEntries(Object.entries(d).map(([k, v]) => [k, round1(v)])),
533
+ ])
534
+ ),
535
+ };
536
+ })(),
505
537
  },
506
538
  items: [...result.details.inline, ...result.details.files],
507
539
  };
@@ -5,34 +5,37 @@
5
5
  (async () => {
6
6
  // --- Helpers ---
7
7
 
8
+ // @shared getRootDomain
8
9
  function getRootDomain(hostname) {
9
- const parts = hostname.split(".");
10
- if (parts.length > 2) {
11
- const sld = parts[parts.length - 2];
12
- if (
13
- sld.length <= 3 &&
14
- ["co", "com", "org", "net", "gov", "edu"].includes(sld)
15
- ) {
16
- return parts.slice(-3).join(".");
17
- }
18
- return parts.slice(-2).join(".");
19
- }
20
- return hostname;
21
- }
22
-
23
- const currentRootDomain = getRootDomain(location.hostname);
24
-
10
+ const host = hostname.replace(/\.$/, "");
11
+ // An IP address has no registrable domain, so each address is its own root
12
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
13
+ const parts = host.split(".");
14
+ if (parts.length <= 2) return host;
15
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
16
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
17
+ const tld = parts[parts.length - 1];
18
+ const sld = parts[parts.length - 2];
19
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
20
+ return parts.slice(-2).join(".");
21
+ }
22
+ // @end-shared getRootDomain
23
+
24
+ // @shared isFirstParty
25
25
  function isFirstParty(hostname) {
26
- return getRootDomain(hostname) === currentRootDomain;
26
+ return getRootDomain(hostname) === getRootDomain(location.hostname);
27
27
  }
28
+ // @end-shared isFirstParty
28
29
 
30
+ // @shared formatBytes
29
31
  function formatBytes(bytes) {
30
- if (!bytes || bytes === 0) return "N/A";
31
- const k = 1024;
32
- const sizes = ["B", "KB", "MB"];
33
- const i = Math.floor(Math.log(bytes) / Math.log(k));
34
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
32
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
33
+ if (bytes === 0) return "0 B";
34
+ const units = ["B", "KB", "MB", "GB"];
35
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
36
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
35
37
  }
38
+ // @end-shared formatBytes
36
39
 
37
40
  function getResourceType(entry) {
38
41
  // Use file extension as primary signal — initiatorType reflects the loader
@@ -424,6 +427,19 @@
424
427
 
425
428
  // --- Phase 7: Console output ---
426
429
 
430
+ if (entries.length === 0) {
431
+ console.group("%c🔍 Cache Strategy Analysis", "font-weight: bold; font-size: 14px;");
432
+ console.warn("⚠️ No resources found in the Performance API buffer.");
433
+ console.log(" This usually means the site called performance.clearResourceTimings() to manage memory.");
434
+ console.log(" Try: reload the page and run this snippet immediately after load.");
435
+ console.groupEnd();
436
+ return {
437
+ script: "Cache-Strategy-Analysis",
438
+ status: "error",
439
+ error: "No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again.",
440
+ };
441
+ }
442
+
427
443
  const allAntiPatterns = entries.flatMap((e) =>
428
444
  e.antiPatterns.map((ap) => ({ ...ap, resource: e.shortName, url: e.url }))
429
445
  );
@@ -703,7 +719,7 @@
703
719
  "🟡 Replace Expires headers with Cache-Control: max-age — Expires is an outdated mechanism and less reliable."
704
720
  );
705
721
  }
706
- if (cacheEfficiencyPercent < 50) {
722
+ if (entries.length > 0 && cacheEfficiencyPercent < 50) {
707
723
  recommendations.push(
708
724
  `🔴 Cache efficiency is low (${cacheEfficiencyPercent}%). Review caching strategy for all static assets.`
709
725
  );
@@ -758,12 +774,20 @@
758
774
  ? "needs-improvement"
759
775
  : "poor";
760
776
 
777
+ const MAX_ITEMS = 50;
778
+ const actionableEntries = entries.filter(
779
+ (e) =>
780
+ e.antiPatterns.length > 0 ||
781
+ ["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
782
+ );
761
783
  return {
762
784
  script: "Cache-Strategy-Analysis",
763
785
  status: "ok",
764
786
  count: entries.length,
787
+ corsLimitedAnalysis: corsRestricted > 0,
765
788
  details: {
766
789
  totalResources: entries.length,
790
+ actionableCount: actionableEntries.length,
767
791
  headersAnalyzed,
768
792
  corsRestricted,
769
793
  excludedFromHeaders: excludedFromHeaders.length,
@@ -780,13 +804,11 @@
780
804
  uncompressedCount: uncompressedResources.length,
781
805
  protocolDistribution,
782
806
  },
783
- // Only resources with actionable issues — omits clean resources to reduce noise
784
- items: entries
785
- .filter(
786
- (e) =>
787
- e.antiPatterns.length > 0 ||
788
- ["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
789
- )
807
+ // Only resources with actionable issues, at most 50: the ones with the most anti-patterns
808
+ // first, then the largest. Clean resources are left out to reduce noise.
809
+ items: actionableEntries
810
+ .sort((a, b) => b.antiPatterns.length - a.antiPatterns.length || b.decodedBodySize - a.decodedBodySize)
811
+ .slice(0, MAX_ITEMS)
790
812
  .map((e) => ({
791
813
  shortName: e.shortName,
792
814
  host: e.host,
@@ -0,0 +1,172 @@
1
+ // Compression Audit
2
+ // Finds text-based resources served without HTTP compression and estimates the savings
3
+ // https://webperf-snippets.nucliweb.net
4
+
5
+ (() => {
6
+ const MIN_BYTES = 1024; // Below this, compression saves too little to matter
7
+ const MAX_ITEMS = 50;
8
+
9
+ // Typical size reduction gzip/brotli achieve per resource type (estimate, not a measurement)
10
+ const SAVINGS_RATIO = { js: 0.7, css: 0.7, html: 0.7, json: 0.7, svg: 0.7, xml: 0.7, font: 0.5 };
11
+
12
+ const EXTENSION_TYPES = {
13
+ js: "js", mjs: "js", cjs: "js",
14
+ css: "css",
15
+ html: "html", htm: "html",
16
+ json: "json", webmanifest: "json",
17
+ svg: "svg",
18
+ xml: "xml", rss: "xml", atom: "xml",
19
+ ttf: "font", otf: "font", eot: "font",
20
+ };
21
+
22
+ // @shared formatBytes
23
+ function formatBytes(bytes) {
24
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
25
+ if (bytes === 0) return "0 B";
26
+ const units = ["B", "KB", "MB", "GB"];
27
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
28
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
29
+ }
30
+ // @end-shared formatBytes
31
+
32
+ const parseUrl = (name) => {
33
+ try {
34
+ return new URL(name);
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+
40
+ // Text type of a resource, or null when it is not a compressible text resource
41
+ const textType = (entry, url) => {
42
+ if (entry.entryType === "navigation") return "html";
43
+ const ext = url.pathname.split(".").pop().toLowerCase();
44
+ if (EXTENSION_TYPES[ext]) return EXTENSION_TYPES[ext];
45
+ if (entry.initiatorType === "script") return "js";
46
+ return null;
47
+ };
48
+
49
+ const entries = [...performance.getEntriesByType("navigation"), ...performance.getEntriesByType("resource")];
50
+ const contentEncodingSupported = entries.some((e) => "contentEncoding" in e);
51
+
52
+ const candidates = [];
53
+ entries.forEach((entry) => {
54
+ const url = parseUrl(entry.name);
55
+ if (!url || !/^https?:$/.test(url.protocol)) return;
56
+ const type = textType(entry, url);
57
+ if (!type) return;
58
+ candidates.push({ entry, url, type });
59
+ });
60
+
61
+ const compressed = [];
62
+ const uncompressed = [];
63
+ let sizeUnknownCount = 0;
64
+ let skippedSmallCount = 0;
65
+ const byEncoding = {};
66
+
67
+ candidates.forEach(({ entry, url, type }) => {
68
+ const decoded = entry.decodedBodySize || 0;
69
+ const encoded = entry.encodedBodySize || 0;
70
+
71
+ // Cross-origin resources without Timing-Allow-Origin report every size as zero
72
+ if (decoded === 0 && url.origin !== location.origin) {
73
+ sizeUnknownCount++;
74
+ return;
75
+ }
76
+ if (decoded < MIN_BYTES) {
77
+ skippedSmallCount++;
78
+ return;
79
+ }
80
+
81
+ const encoding = (entry.contentEncoding || "").toLowerCase();
82
+ // Without contentEncoding (older browsers), a body smaller than its decoded form was compressed
83
+ const isCompressed = encoding ? encoding !== "identity" : encoded < decoded;
84
+
85
+ if (isCompressed) {
86
+ const label = encoding || "unspecified";
87
+ byEncoding[label] = (byEncoding[label] || 0) + 1;
88
+ compressed.push(entry);
89
+ return;
90
+ }
91
+
92
+ const savings = Math.round(decoded * SAVINGS_RATIO[type]);
93
+ uncompressed.push({
94
+ url: entry.name,
95
+ shortName: url.pathname.split("/").filter(Boolean).pop() || url.hostname,
96
+ type,
97
+ encoding: "none",
98
+ encodedBytes: encoded,
99
+ decodedBytes: decoded,
100
+ estimatedSavingsBytes: savings,
101
+ });
102
+ });
103
+
104
+ byEncoding.none = uncompressed.length;
105
+ uncompressed.sort((a, b) => b.estimatedSavingsBytes - a.estimatedSavingsBytes);
106
+
107
+ const totalUncompressedBytes = uncompressed.reduce((n, i) => n + i.decodedBytes, 0);
108
+ const estimatedSavingsBytes = uncompressed.reduce((n, i) => n + i.estimatedSavingsBytes, 0);
109
+ const corsLimitedAnalysis = sizeUnknownCount > 0;
110
+
111
+ const issues = [];
112
+ if (uncompressed.length > 0) {
113
+ issues.push({
114
+ severity: "warning",
115
+ message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
116
+ });
117
+ }
118
+ if (corsLimitedAnalysis) {
119
+ issues.push({
120
+ severity: "info",
121
+ message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
122
+ });
123
+ }
124
+ if (!contentEncodingSupported && candidates.length > 0) {
125
+ issues.push({
126
+ severity: "info",
127
+ message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
128
+ });
129
+ }
130
+
131
+ console.group("%c🗜️ Compression Audit", "font-weight: bold; font-size: 14px;");
132
+ console.log(
133
+ `Text resources: ${candidates.length} | compressed: ${compressed.length} | uncompressed: ${uncompressed.length} | size unknown: ${sizeUnknownCount}`
134
+ );
135
+ if (uncompressed.length > 0) {
136
+ console.log(`Estimated savings: ~${formatBytes(estimatedSavingsBytes)} of ${formatBytes(totalUncompressedBytes)}`);
137
+ console.table(
138
+ uncompressed.slice(0, MAX_ITEMS).map((i) => ({
139
+ Resource: i.shortName,
140
+ Type: i.type,
141
+ Size: formatBytes(i.decodedBytes),
142
+ "Est. savings": formatBytes(i.estimatedSavingsBytes),
143
+ }))
144
+ );
145
+ } else {
146
+ console.log("%c✅ No uncompressed text resources found", "color: #22c55e;");
147
+ }
148
+ if (corsLimitedAnalysis) {
149
+ console.log("%cℹ️ Some cross-origin resources could not be analyzed (no Timing-Allow-Origin)", "color: #3b82f6;");
150
+ }
151
+ console.groupEnd();
152
+
153
+ return {
154
+ script: "Compression-Audit",
155
+ status: "ok",
156
+ count: uncompressed.length,
157
+ corsLimitedAnalysis,
158
+ details: {
159
+ totalTextResources: candidates.length,
160
+ compressedCount: compressed.length,
161
+ uncompressedCount: uncompressed.length,
162
+ sizeUnknownCount,
163
+ skippedSmallCount,
164
+ totalUncompressedBytes,
165
+ estimatedSavingsBytes,
166
+ byEncoding,
167
+ contentEncodingSupported,
168
+ },
169
+ items: uncompressed.slice(0, MAX_ITEMS),
170
+ issues,
171
+ };
172
+ })();
@@ -345,8 +345,13 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
345
345
  ...cvResults.autoElements.map(el => ({ ...el, type: "auto" })),
346
346
  ...cvResults.hiddenElements.map(el => ({ ...el, type: "hidden" })),
347
347
  ],
348
- issues: cvResults.autoElements
349
- .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
350
- .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
348
+ issues: [
349
+ ...cvResults.autoElements
350
+ .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
351
+ .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
352
+ ...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
353
+ ? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
354
+ : []),
355
+ ],
351
356
  };
352
357
  })();
@@ -3,13 +3,15 @@
3
3
  // https://webperf-snippets.nucliweb.net
4
4
 
5
5
  (() => {
6
- const formatBytes = (bytes) => {
6
+ // @shared formatBytes
7
+ function formatBytes(bytes) {
8
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
7
9
  if (bytes === 0) return "0 B";
8
- const k = 1024;
9
- const sizes = ["B", "KB", "MB"];
10
- const i = Math.floor(Math.log(bytes) / Math.log(k));
11
- return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
12
- };
10
+ const units = ["B", "KB", "MB", "GB"];
11
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
12
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
13
+ }
14
+ // @end-shared formatBytes
13
15
 
14
16
  const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
15
17
 
@@ -18,6 +18,12 @@
18
18
  new PerformanceObserver((list) => {
19
19
  const [entry] = list.getEntries();
20
20
 
21
+ // loadEventEnd is 0 until the load event finishes: the phases would be meaningless
22
+ if (!entry.loadEventEnd) {
23
+ console.log("%c⏳ The page is still loading. Run this snippet again after the load event.", "color: #f59e0b;");
24
+ return;
25
+ }
26
+
21
27
  const formatMs = (ms) => ms.toFixed(2) + " ms";
22
28
  const formatBar = (ms, total) => {
23
29
  const pct = total > 0 ? (ms / total) * 100 : 0;
@@ -104,7 +110,7 @@
104
110
  // Key milestones
105
111
  console.log("");
106
112
  console.log("%cKey Milestones:", "font-weight: bold;");
107
- console.log(` TTFB (Time to First Byte): ${entry.responseStart.toFixed(0)}ms`);
113
+ console.log(` TTFB (Time to First Byte): ${Math.max(0, entry.responseStart - (entry.activationStart || 0)).toFixed(0)}ms`);
108
114
  console.log(` DOMContentLoaded: ${entry.domContentLoadedEventEnd.toFixed(0)}ms`);
109
115
  console.log(` Load Complete: ${entry.loadEventEnd.toFixed(0)}ms`);
110
116
 
@@ -142,6 +148,13 @@
142
148
  // Synchronous return for agent
143
149
  const [navSync] = performance.getEntriesByType("navigation");
144
150
  if (!navSync) return { script: "Event-Processing-Time", status: "error", error: "No navigation entry" };
151
+ if (!navSync.loadEventEnd) {
152
+ return {
153
+ script: "Event-Processing-Time",
154
+ status: "tracking",
155
+ message: "The page is still loading. Run this snippet again after the load event.",
156
+ };
157
+ }
145
158
  const totalTimeSync = navSync.loadEventEnd - navSync.startTime;
146
159
  const networkTimeSync = (navSync.responseEnd || 0) - navSync.startTime;
147
160
  const ratingSync = totalTimeSync <= 2500 ? "good" : totalTimeSync <= 4000 ? "needs-improvement" : "poor";
@@ -169,7 +182,7 @@
169
182
  details: {
170
183
  networkTimeMs: Math.round(networkTimeSync),
171
184
  processingTimeMs: Math.round(totalTimeSync - networkTimeSync),
172
- ttfbMs: Math.round(navSync.responseStart),
185
+ ttfbMs: Math.round(Math.max(0, navSync.responseStart - (navSync.activationStart || 0))),
173
186
  domContentLoadedMs: Math.round(navSync.domContentLoadedEventEnd),
174
187
  loadCompleteMs: Math.round(navSync.loadEventEnd),
175
188
  phases: Object.fromEntries(phasesSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
@@ -18,12 +18,14 @@
18
18
  const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
19
19
  if (!fcpEntry) return;
20
20
 
21
- const fcpTime = fcpEntry.startTime;
21
+ const navEntry = performance.getEntriesByType("navigation")[0];
22
+ // Prerendered pages: measure from activation, not from the start of the prerender
23
+ const activationStart = navEntry?.activationStart || 0;
24
+ const fcpTime = Math.max(0, fcpEntry.startTime - activationStart);
22
25
  const rating = valueToRating(fcpTime);
23
26
  const { icon, color } = RATING[rating];
24
27
 
25
- const navEntry = performance.getEntriesByType("navigation")[0];
26
- const ttfb = navEntry?.responseStart ?? 0;
28
+ const ttfb = Math.max(0, (navEntry?.responseStart ?? 0) - activationStart);
27
29
 
28
30
  // Render-blocking resources (Chrome 107+, requires renderBlockingStatus API)
29
31
  const resources = performance.getEntriesByType("resource");
@@ -32,7 +34,7 @@
32
34
  );
33
35
 
34
36
  const lastBlockingEnd = blockingResources.length
35
- ? Math.max(...blockingResources.map((r) => r.responseEnd))
37
+ ? Math.max(...blockingResources.map((r) => r.responseEnd)) - activationStart
36
38
  : ttfb;
37
39
 
38
40
  console.group(
@@ -87,8 +89,25 @@
87
89
  // Synchronous return for agent
88
90
  const fcpEntrySync = performance.getEntriesByName("first-contentful-paint")[0];
89
91
  if (!fcpEntrySync) return { script: "FCP", status: "error", error: "No FCP entry yet" };
90
- const fcpTimeSync = fcpEntrySync.startTime;
92
+ const navEntrySync = performance.getEntriesByType("navigation")[0];
93
+ const activationStartSync = navEntrySync?.activationStart || 0;
94
+ const fcpTimeSync = Math.max(0, fcpEntrySync.startTime - activationStartSync);
91
95
  const ratingSync = valueToRating(fcpTimeSync);
96
+
97
+ // Phases, measured from activation like the value: TTFB, then the wait for the last
98
+ // render-blocking resource, then the rest until the paint
99
+ const ttfbSync = Math.max(0, (navEntrySync?.responseStart ?? 0) - activationStartSync);
100
+ const blockingSync = performance
101
+ .getEntriesByType("resource")
102
+ .filter((r) => r.renderBlockingStatus === "blocking");
103
+ const lastBlockingEndSync = Math.max(
104
+ ttfbSync,
105
+ ...blockingSync.map((r) => r.responseEnd - activationStartSync)
106
+ );
107
+ const ttfbMs = Math.round(ttfbSync);
108
+ const renderBlockingLoadMs = Math.round(lastBlockingEndSync - ttfbSync);
109
+ const renderDelayMs = Math.max(0, Math.round(fcpTimeSync) - ttfbMs - renderBlockingLoadMs);
110
+
92
111
  return {
93
112
  script: "FCP",
94
113
  status: "ok",
@@ -97,5 +116,20 @@
97
116
  unit: "ms",
98
117
  rating: ratingSync,
99
118
  thresholds: { good: 1800, needsImprovement: 3000 },
119
+ details: {
120
+ ttfbMs,
121
+ renderBlockingLoadMs,
122
+ renderDelayMs,
123
+ blockingResourceCount: blockingSync.length,
124
+ },
125
+ // Render-blocking resources, the last to finish first (at most 50)
126
+ items: [...blockingSync]
127
+ .sort((a, b) => b.responseEnd - a.responseEnd)
128
+ .slice(0, 50)
129
+ .map((r) => ({
130
+ url: r.name.split("/").pop().split("?")[0] || r.name,
131
+ type: r.initiatorType === "link" ? "CSS" : "JS",
132
+ durationMs: Math.round(r.duration),
133
+ })),
100
134
  };
101
135
  })();
@@ -246,7 +246,7 @@
246
246
  selector: image.selector,
247
247
  lazyType: image.lazyType,
248
248
  dimensions: image.dimensions,
249
- position: image.position,
249
+ position: `${image.position.top}px, ${image.position.left}px`,
250
250
  distanceFromEdge: image.distanceFromEdge,
251
251
  src: image.src,
252
252
  srcset: image.srcset,
@@ -78,13 +78,15 @@
78
78
  return 0;
79
79
  }
80
80
 
81
+ // @shared formatBytes
81
82
  function formatBytes(bytes) {
82
- if (bytes === 0) return "0 Bytes";
83
- const k = 1024;
84
- const sizes = ["Bytes", "KB", "MB", "GB"];
85
- const i = Math.floor(Math.log(bytes) / Math.log(k));
86
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
83
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
84
+ if (bytes === 0) return "0 B";
85
+ const units = ["B", "KB", "MB", "GB"];
86
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
87
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
87
88
  }
89
+ // @end-shared formatBytes
88
90
 
89
91
  // Find LCP candidate (largest visible image) to exclude from recommendations
90
92
  const allViewportImages = Array.from(document.querySelectorAll("img")).filter(