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.
- package/README.md +120 -5
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +59 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +22 -3
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +55 -3
- package/src/crux.js +84 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +22 -1
- 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 ===
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
62
|
-
return
|
|
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:
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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) ===
|
|
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 (
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
34
|
-
return (bytes / Math.pow(
|
|
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
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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:
|
|
349
|
-
.
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
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" }])),
|
package/snippets/Loading/FCP.js
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
|
|
19
19
|
if (!fcpEntry) return;
|
|
20
20
|
|
|
21
|
-
const
|
|
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
|
|
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
|
|
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 ===
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
86
|
-
return
|
|
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(
|