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
|
@@ -2,15 +2,28 @@
|
|
|
2
2
|
// Default minWidth = 768 (px), but you can customize it
|
|
3
3
|
|
|
4
4
|
async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
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
|
+
const SCRIPT = "CSS-Media-Queries-Analysis";
|
|
23
|
+
const MAX_ITEMS = 50;
|
|
24
|
+
const WARNING_BYTES = 50 * 1024;
|
|
5
25
|
const stylesheets = [...document.styleSheets];
|
|
6
|
-
const
|
|
7
|
-
const fileMediaQueries = [];
|
|
8
|
-
let inlineTotalClasses = 0;
|
|
9
|
-
let inlineTotalProperties = 0;
|
|
10
|
-
let filesTotalClasses = 0;
|
|
11
|
-
let filesTotalProperties = 0;
|
|
12
|
-
let inlineTotalBytes = 0;
|
|
13
|
-
let filesTotalBytes = 0;
|
|
26
|
+
const rules = []; // one per @media rule: { source, inline, mediaQuery, classes, properties, bytes }
|
|
14
27
|
let corsBlockedCount = 0;
|
|
15
28
|
|
|
16
29
|
// Helper to check if media query targets bigger than specified breakpoint
|
|
@@ -28,24 +41,16 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
28
41
|
if (unit === "rem" && value > minWidth / 16) return true; // Convert to rem
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
//
|
|
32
|
-
const maxWidthMatch = mediaText.match(/max-width:\s*(\d+)(px|em|rem)/i);
|
|
33
|
-
if (maxWidthMatch && !minWidthMatch) {
|
|
34
|
-
return false; // max-width only queries are for smaller viewports
|
|
35
|
-
}
|
|
36
|
-
|
|
44
|
+
// max-width only queries are for smaller viewports
|
|
37
45
|
return false;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
classes: classMatches.length,
|
|
47
|
-
properties: propertyMatches.length,
|
|
48
|
-
};
|
|
48
|
+
// Classes and declarations inside the rule, not in its media query. A class starts with a
|
|
49
|
+
// letter, an underscore or a hyphen, so values such as 1.5rem do not count
|
|
50
|
+
function countClassesAndProperties(innerCss) {
|
|
51
|
+
const classMatches = innerCss.match(/\.-?[_a-zA-Z][\w-]*/g) || [];
|
|
52
|
+
const propertyMatches = innerCss.match(/[a-z-]+\s*:/g) || [];
|
|
53
|
+
return { classes: classMatches.length, properties: propertyMatches.length };
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
// Helper to calculate byte size
|
|
@@ -64,480 +69,140 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
64
69
|
}
|
|
65
70
|
// @end-shared formatBytes
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
function addRule(source, inline, mediaQuery, fullCss, innerCss) {
|
|
73
|
+
if (!isBiggerThanBreakpoint(mediaQuery)) return;
|
|
74
|
+
rules.push({ source, inline, mediaQuery, ...countClassesAndProperties(innerCss), bytes: getByteSize(fullCss) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse CSS text to find @media rules, for stylesheets read with fetch
|
|
78
|
+
function parseMediaQueriesFromCSS(cssText, source) {
|
|
70
79
|
const mediaRegex = /@media\s*([^{]+)\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
71
80
|
let match;
|
|
72
|
-
|
|
73
81
|
while ((match = mediaRegex.exec(cssText)) !== null) {
|
|
74
|
-
|
|
75
|
-
const mediaContent = match[2];
|
|
76
|
-
|
|
77
|
-
if (isBiggerThanBreakpoint(mediaText)) {
|
|
78
|
-
const counts = countClassesAndProperties(mediaContent);
|
|
79
|
-
const byteSize = getByteSize(match[0]);
|
|
80
|
-
|
|
81
|
-
const mediaQueryData = {
|
|
82
|
-
source: source,
|
|
83
|
-
mediaQuery: mediaText,
|
|
84
|
-
classes: counts.classes,
|
|
85
|
-
properties: counts.properties,
|
|
86
|
-
bytes: byteSize,
|
|
87
|
-
bytesFormatted: formatBytes(byteSize),
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
if (isInline) {
|
|
91
|
-
inlineMediaQueries.push(mediaQueryData);
|
|
92
|
-
inlineTotalClasses += counts.classes;
|
|
93
|
-
inlineTotalProperties += counts.properties;
|
|
94
|
-
inlineTotalBytes += byteSize;
|
|
95
|
-
} else {
|
|
96
|
-
fileMediaQueries.push(mediaQueryData);
|
|
97
|
-
filesTotalClasses += counts.classes;
|
|
98
|
-
filesTotalProperties += counts.properties;
|
|
99
|
-
filesTotalBytes += byteSize;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
82
|
+
addRule(source, false, match[1].trim(), match[0], match[2]);
|
|
102
83
|
}
|
|
103
84
|
}
|
|
104
85
|
|
|
105
86
|
// Process stylesheets
|
|
106
87
|
for (let sheetIndex = 0; sheetIndex < stylesheets.length; sheetIndex++) {
|
|
107
88
|
const sheet = stylesheets[sheetIndex];
|
|
108
|
-
const
|
|
89
|
+
const inline = !sheet.href;
|
|
109
90
|
const source = sheet.href || `<style> tag #${sheetIndex}`;
|
|
110
91
|
|
|
111
92
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
[...rules].forEach((rule) => {
|
|
93
|
+
const sheetRules = sheet.cssRules || sheet.rules;
|
|
94
|
+
if (sheetRules) {
|
|
95
|
+
[...sheetRules].forEach((rule) => {
|
|
116
96
|
if (rule.type === CSSRule.MEDIA_RULE) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
const cssText = rule.cssText;
|
|
120
|
-
const counts = countClassesAndProperties(cssText);
|
|
121
|
-
const byteSize = getByteSize(cssText);
|
|
122
|
-
|
|
123
|
-
const mediaQueryData = {
|
|
124
|
-
source: source,
|
|
125
|
-
mediaQuery: mediaText,
|
|
126
|
-
classes: counts.classes,
|
|
127
|
-
properties: counts.properties,
|
|
128
|
-
bytes: byteSize,
|
|
129
|
-
bytesFormatted: formatBytes(byteSize),
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
if (isInline) {
|
|
133
|
-
inlineMediaQueries.push(mediaQueryData);
|
|
134
|
-
inlineTotalClasses += counts.classes;
|
|
135
|
-
inlineTotalProperties += counts.properties;
|
|
136
|
-
inlineTotalBytes += byteSize;
|
|
137
|
-
} else {
|
|
138
|
-
fileMediaQueries.push(mediaQueryData);
|
|
139
|
-
filesTotalClasses += counts.classes;
|
|
140
|
-
filesTotalProperties += counts.properties;
|
|
141
|
-
filesTotalBytes += byteSize;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
97
|
+
const inner = [...rule.cssRules].map((r) => r.cssText).join("");
|
|
98
|
+
addRule(source, inline, rule.media.mediaText, rule.cssText, inner);
|
|
144
99
|
}
|
|
145
100
|
});
|
|
146
101
|
}
|
|
147
102
|
} catch {
|
|
148
|
-
//
|
|
103
|
+
// Cross-origin stylesheet without CORS: try to fetch it
|
|
149
104
|
if (sheet.href) {
|
|
150
105
|
try {
|
|
151
|
-
const response = await fetch(sheet.href);
|
|
152
|
-
|
|
153
|
-
parseMediaQueriesFromCSS(cssText, sheet.href, false);
|
|
106
|
+
const response = await fetch(ownRequestUrl(sheet.href));
|
|
107
|
+
parseMediaQueriesFromCSS(await response.text(), sheet.href);
|
|
154
108
|
} catch {
|
|
155
|
-
// Silently count CORS blocked files
|
|
156
109
|
corsBlockedCount++;
|
|
157
110
|
}
|
|
158
111
|
}
|
|
159
112
|
}
|
|
160
113
|
}
|
|
161
114
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(
|
|
174
|
-
`%cEstimated CSS bytes that mobile doesn't need: ${formatBytes(totalBytes)}`,
|
|
175
|
-
"font-weight: bold; color: #22c55e; font-size: 14px;",
|
|
176
|
-
);
|
|
177
|
-
console.log(` āā From inline CSS: ${formatBytes(inlineTotalBytes)}`);
|
|
178
|
-
console.log(` āā From external files: ${formatBytes(filesTotalBytes)}`);
|
|
179
|
-
console.log("");
|
|
180
|
-
console.log("š” By splitting these styles into separate files with media queries,");
|
|
181
|
-
console.log(" mobile users won't need to download, parse, or process this CSS.");
|
|
182
|
-
console.groupEnd();
|
|
183
|
-
|
|
184
|
-
// Inline CSS Results
|
|
185
|
-
console.group("š· INLINE CSS (<style> tags)");
|
|
186
|
-
console.log(`Media queries: ${inlineMediaQueries.length}`);
|
|
187
|
-
console.log(`Classes: ${inlineTotalClasses}`);
|
|
188
|
-
console.log(`Properties: ${inlineTotalProperties}`);
|
|
189
|
-
console.log(`Total size: ${formatBytes(inlineTotalBytes)}`);
|
|
190
|
-
if (inlineMediaQueries.length === 0) {
|
|
191
|
-
console.log(`No inline media queries found for viewports > ${minWidth}px.`);
|
|
115
|
+
// --- One item per distinct query ------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const byQuery = new Map();
|
|
118
|
+
for (const r of rules) {
|
|
119
|
+
const q = byQuery.get(r.mediaQuery) ?? { mediaQuery: r.mediaQuery, rules: 0, sources: new Set(), classes: 0, properties: 0, bytes: 0 };
|
|
120
|
+
q.rules++;
|
|
121
|
+
q.sources.add(r.source);
|
|
122
|
+
q.classes += r.classes;
|
|
123
|
+
q.properties += r.properties;
|
|
124
|
+
q.bytes += r.bytes;
|
|
125
|
+
byQuery.set(r.mediaQuery, q);
|
|
192
126
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
127
|
+
const queries = [...byQuery.values()]
|
|
128
|
+
.map(({ sources, ...q }) => ({
|
|
129
|
+
mediaQuery: q.mediaQuery,
|
|
130
|
+
rules: q.rules,
|
|
131
|
+
files: sources.size,
|
|
132
|
+
classes: q.classes,
|
|
133
|
+
properties: q.properties,
|
|
134
|
+
bytes: q.bytes,
|
|
135
|
+
}))
|
|
136
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
137
|
+
|
|
138
|
+
const bySourceMap = new Map();
|
|
139
|
+
for (const r of rules) {
|
|
140
|
+
const s = bySourceMap.get(r.source) ?? { source: r.source, inline: r.inline, rules: 0, bytes: 0 };
|
|
141
|
+
s.rules++;
|
|
142
|
+
s.bytes += r.bytes;
|
|
143
|
+
bySourceMap.set(r.source, s);
|
|
144
|
+
}
|
|
145
|
+
const bySource = [...bySourceMap.values()];
|
|
146
|
+
|
|
147
|
+
const totalBytes = rules.reduce((sum, r) => sum + r.bytes, 0);
|
|
148
|
+
const uniquenessRatio = rules.length ? Math.round((queries.length / rules.length) * 1000) / 1000 : 0;
|
|
149
|
+
|
|
150
|
+
// --- Issues ----------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const issues = [];
|
|
153
|
+
if (rules.length === 0) {
|
|
154
|
+
issues.push({ severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
|
|
155
|
+
} else {
|
|
156
|
+
issues.push({
|
|
157
|
+
severity: totalBytes > WARNING_BYTES ? "warning" : "info",
|
|
158
|
+
message: `${rules.length} @media rules for viewports wider than ${minWidth}px add ${formatBytes(totalBytes)} of CSS that narrower screens download and parse without using. Move them to a stylesheet with a media attribute, such as <link rel="stylesheet" href="wide.css" media="(min-width: ${minWidth + 1}px)">, which narrower screens fetch at low priority without blocking rendering`,
|
|
159
|
+
});
|
|
160
|
+
if (rules.length >= 20 && uniquenessRatio < 0.1) {
|
|
161
|
+
issues.push({
|
|
162
|
+
severity: "info",
|
|
163
|
+
message: `${queries.length} distinct queries repeat across ${rules.length} @media blocks, the pattern of component-scoped or compiled CSS. Grouping the rules of each query under one block shortens the stylesheets`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
203
166
|
}
|
|
204
|
-
console.groupEnd();
|
|
205
|
-
|
|
206
|
-
// CORS warning if applicable
|
|
207
167
|
if (corsBlockedCount > 0) {
|
|
208
|
-
|
|
209
|
-
console.log(
|
|
210
|
-
`%c${corsBlockedCount} external CSS file(s) could not be analyzed due to CORS restrictions.`,
|
|
211
|
-
"color: #f59e0b; font-weight: bold",
|
|
212
|
-
);
|
|
213
|
-
console.log("");
|
|
214
|
-
console.log("These files are loaded by the browser but cannot be read via JavaScript.");
|
|
215
|
-
console.log("The analysis above reflects only the CSS files that were accessible.");
|
|
216
|
-
console.log("");
|
|
217
|
-
console.log("š” To analyze CORS-blocked files:");
|
|
218
|
-
console.log(" ⢠Download the CSS files manually and run the analysis locally");
|
|
219
|
-
console.log(" ⢠Use Chrome DevTools Coverage tab to measure unused CSS");
|
|
220
|
-
console.log(" ⢠Run this analysis from the same origin as the CSS files");
|
|
221
|
-
console.groupEnd();
|
|
168
|
+
issues.push({ severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
|
|
222
169
|
}
|
|
223
170
|
|
|
224
|
-
|
|
225
|
-
summary: {
|
|
226
|
-
total: {
|
|
227
|
-
mediaQueries: inlineMediaQueries.length + fileMediaQueries.length,
|
|
228
|
-
classes: inlineTotalClasses + filesTotalClasses,
|
|
229
|
-
properties: inlineTotalProperties + filesTotalProperties,
|
|
230
|
-
bytes: totalBytes,
|
|
231
|
-
bytesFormatted: formatBytes(totalBytes),
|
|
232
|
-
},
|
|
233
|
-
inline: {
|
|
234
|
-
mediaQueries: inlineMediaQueries.length,
|
|
235
|
-
classes: inlineTotalClasses,
|
|
236
|
-
properties: inlineTotalProperties,
|
|
237
|
-
bytes: inlineTotalBytes,
|
|
238
|
-
bytesFormatted: formatBytes(inlineTotalBytes),
|
|
239
|
-
},
|
|
240
|
-
files: {
|
|
241
|
-
mediaQueries: fileMediaQueries.length,
|
|
242
|
-
classes: filesTotalClasses,
|
|
243
|
-
properties: filesTotalProperties,
|
|
244
|
-
bytes: filesTotalBytes,
|
|
245
|
-
bytesFormatted: formatBytes(filesTotalBytes),
|
|
246
|
-
},
|
|
247
|
-
corsBlocked: corsBlockedCount,
|
|
248
|
-
},
|
|
249
|
-
details: {
|
|
250
|
-
inline: inlineMediaQueries,
|
|
251
|
-
files: fileMediaQueries,
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// CSS Performance Impact Analyzer
|
|
257
|
-
// Estimates the real-world performance cost of unnecessary CSS on mobile devices
|
|
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
|
-
|
|
310
|
-
async function analyzeCSSPerformanceImpact(minWidth = 768) {
|
|
311
|
-
console.log("š Analyzing CSS performance impact...\n");
|
|
312
|
-
const deviceProfiles = getDeviceProfiles();
|
|
171
|
+
// --- Console output --------------------------------------------------------------------
|
|
313
172
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (unnecessaryBytes === 0 && mediaQueryResults.summary.corsBlocked > 0) {
|
|
320
|
-
console.log(
|
|
321
|
-
"%cā ļø Unable to perform performance analysis",
|
|
322
|
-
"color: #f59e0b; font-weight: bold; font-size: 14px",
|
|
173
|
+
console.group(`CSS Media Queries Analysis (min-width > ${minWidth}px)`);
|
|
174
|
+
console.log(`@media rules: ${rules.length} | distinct queries: ${queries.length} | size: ${formatBytes(totalBytes)}`);
|
|
175
|
+
if (queries.length > 0) {
|
|
176
|
+
console.table(
|
|
177
|
+
queries.map((q) => ({ Query: q.mediaQuery, Rules: q.rules, Files: q.files, Classes: q.classes, Properties: q.properties, Size: formatBytes(q.bytes) }))
|
|
323
178
|
);
|
|
324
|
-
console.
|
|
325
|
-
console.log(`All CSS files (${mediaQueryResults.summary.corsBlocked}) are blocked by CORS.`);
|
|
326
|
-
console.log("No desktop-specific @media queries could be analyzed.");
|
|
327
|
-
console.log("");
|
|
328
|
-
console.log("Please try one of these alternatives:");
|
|
329
|
-
console.log(" ⢠Run this script from the same domain as the CSS files");
|
|
330
|
-
console.log(" ⢠Use Chrome DevTools Coverage tab for manual analysis");
|
|
331
|
-
console.log(" ⢠Download CSS files and analyze them locally");
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (unnecessaryBytes === 0) {
|
|
336
|
-
console.log("%cā
Great news!", "color: #22c55e; font-weight: bold; font-size: 14px");
|
|
337
|
-
console.log("");
|
|
338
|
-
console.log("No desktop-specific CSS found (min-width > " + minWidth + "px).");
|
|
339
|
-
console.log("This site appears to be optimized for mobile-first delivery.");
|
|
340
|
-
return null;
|
|
179
|
+
console.table(bySource.map((s) => ({ Source: s.source, Rules: s.rules, Size: formatBytes(s.bytes) })));
|
|
341
180
|
}
|
|
342
|
-
|
|
343
|
-
const totalClasses = mediaQueryResults.summary.total.classes;
|
|
344
|
-
const totalProperties = mediaQueryResults.summary.total.properties;
|
|
345
|
-
|
|
346
|
-
console.group("ā” PERFORMANCE IMPACT ANALYSIS");
|
|
347
|
-
console.log(`Unnecessary CSS size: ${mediaQueryResults.summary.total.bytesFormatted}`);
|
|
348
|
-
console.log(`Classes to process: ${totalClasses}`);
|
|
349
|
-
console.log(`Properties to compute: ${totalProperties}\n`);
|
|
350
|
-
|
|
351
|
-
Object.entries(deviceProfiles).forEach(([deviceName, profile]) => {
|
|
352
|
-
console.group(`š± ${deviceName}`);
|
|
353
|
-
console.log(` ${profile.description}`);
|
|
354
|
-
console.log("");
|
|
355
|
-
|
|
356
|
-
// 1. Network download time
|
|
357
|
-
const downloadTimeMs = (unnecessaryBytes * 8) / (profile.networkSpeed * 1000);
|
|
358
|
-
|
|
359
|
-
// 2. CSS Parsing time (converting bytes to styles)
|
|
360
|
-
const parsingTimeMs = (unnecessaryBytes / (1024 * 1024) / profile.cssParsingSpeed) * 1000;
|
|
361
|
-
|
|
362
|
-
// 3. CSSOM construction (creating the CSS Object Model)
|
|
363
|
-
// Approximation: ~0.01ms per CSS property on mid-range devices
|
|
364
|
-
const cssomConstructionMs = totalProperties * 0.01 * profile.cssSelectorMatchingMultiplier;
|
|
365
|
-
|
|
366
|
-
// 4. Selector matching overhead
|
|
367
|
-
// Each class needs to be evaluated against the DOM (even if it doesn't match)
|
|
368
|
-
// ~0.005ms per selector on mid-range devices
|
|
369
|
-
const selectorMatchingMs = totalClasses * 0.005 * profile.cssSelectorMatchingMultiplier;
|
|
370
|
-
|
|
371
|
-
// 5. Style recalculation overhead during interactions
|
|
372
|
-
// More CSS rules = more time in each style recalc
|
|
373
|
-
const recalcOverheadMs = totalProperties * 0.002 * profile.cssSelectorMatchingMultiplier;
|
|
374
|
-
|
|
375
|
-
const totalBlockingTime = downloadTimeMs + parsingTimeMs + cssomConstructionMs;
|
|
376
|
-
const totalRuntimeOverhead = selectorMatchingMs + recalcOverheadMs;
|
|
377
|
-
|
|
378
|
-
console.log("š¦ Render-blocking impact:");
|
|
379
|
-
console.log(` ⢠Network download: ${downloadTimeMs.toFixed(2)}ms`);
|
|
380
|
-
console.log(` ⢠CSS parsing: ${parsingTimeMs.toFixed(2)}ms`);
|
|
381
|
-
console.log(` ⢠CSSOM construction: ${cssomConstructionMs.toFixed(2)}ms`);
|
|
382
|
-
console.log(
|
|
383
|
-
` š Total blocking time: %c${totalBlockingTime.toFixed(2)}ms`,
|
|
384
|
-
"font-weight: bold; color: #ef4444",
|
|
385
|
-
);
|
|
386
|
-
console.log("");
|
|
387
|
-
|
|
388
|
-
console.log("āļø Runtime overhead (per page interaction):");
|
|
389
|
-
console.log(` ⢠Selector matching: ${selectorMatchingMs.toFixed(2)}ms`);
|
|
390
|
-
console.log(` ⢠Style recalculation: ${recalcOverheadMs.toFixed(2)}ms`);
|
|
391
|
-
console.log(
|
|
392
|
-
` š Total per-interaction: %c${totalRuntimeOverhead.toFixed(2)}ms`,
|
|
393
|
-
"font-weight: bold; color: #f59e0b",
|
|
394
|
-
);
|
|
395
|
-
console.log("");
|
|
396
|
-
|
|
397
|
-
// Core Web Vitals impact estimation
|
|
398
|
-
console.log("š Core Web Vitals Impact:");
|
|
399
|
-
|
|
400
|
-
// FCP impact (part of blocking time)
|
|
401
|
-
const fcpImpact = totalBlockingTime * 0.6; // ~60% affects FCP
|
|
402
|
-
console.log(` ⢠FCP delay: ~${fcpImpact.toFixed(0)}ms`);
|
|
403
|
-
|
|
404
|
-
// LCP impact (if there are images/text affected by these styles)
|
|
405
|
-
const lcpImpact = totalBlockingTime * 0.4;
|
|
406
|
-
console.log(` ⢠LCP delay: ~${lcpImpact.toFixed(0)}ms`);
|
|
407
|
-
|
|
408
|
-
// INP impact (runtime overhead affects each interaction)
|
|
409
|
-
console.log(` ⢠INP overhead: ~${totalRuntimeOverhead.toFixed(0)}ms per interaction`);
|
|
410
|
-
|
|
411
|
-
// TBT (Total Blocking Time) - time where main thread is blocked
|
|
412
|
-
const tbtContribution = Math.max(0, totalBlockingTime - 50); // Tasks >50ms contribute to TBT
|
|
413
|
-
console.log(` ⢠TBT contribution: ~${tbtContribution.toFixed(0)}ms`);
|
|
414
|
-
|
|
415
|
-
console.groupEnd();
|
|
416
|
-
console.log("");
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
console.groupEnd();
|
|
420
|
-
|
|
421
|
-
// Savings summary
|
|
422
|
-
console.group("š° POTENTIAL SAVINGS");
|
|
423
|
-
|
|
424
|
-
const midRangeProfile = deviceProfiles["Mid-range (Moto G Power, iPhone SE)"];
|
|
425
|
-
const midRangeDownload = (unnecessaryBytes * 8) / (midRangeProfile.networkSpeed * 1000);
|
|
426
|
-
const midRangeParsing =
|
|
427
|
-
(unnecessaryBytes / (1024 * 1024) / midRangeProfile.cssParsingSpeed) * 1000;
|
|
428
|
-
const midRangeCSSOM = totalProperties * 0.01 * midRangeProfile.cssSelectorMatchingMultiplier;
|
|
429
|
-
const midRangeTotalBlocking = midRangeDownload + midRangeParsing + midRangeCSSOM;
|
|
430
|
-
|
|
431
|
-
console.log("For the median mobile user (mid-range device):");
|
|
432
|
-
console.log("");
|
|
433
|
-
console.log(
|
|
434
|
-
`%cā Eliminate ${midRangeTotalBlocking.toFixed(0)}ms of render-blocking time`,
|
|
435
|
-
"font-weight: bold; color: #22c55e; font-size: 13px",
|
|
436
|
-
);
|
|
437
|
-
console.log(
|
|
438
|
-
`%cā Reduce INP by ~${(totalProperties * 0.002 * midRangeProfile.cssSelectorMatchingMultiplier).toFixed(0)}ms per interaction`,
|
|
439
|
-
"font-weight: bold; color: #22c55e; font-size: 13px",
|
|
440
|
-
);
|
|
441
|
-
console.log(
|
|
442
|
-
`%cā Save ${mediaQueryResults.summary.total.bytesFormatted} of bandwidth`,
|
|
443
|
-
"font-weight: bold; color: #22c55e; font-size: 13px",
|
|
444
|
-
);
|
|
445
|
-
console.log("");
|
|
446
|
-
|
|
447
|
-
console.log("Implementation strategy:");
|
|
448
|
-
console.log("1. Split desktop-specific CSS into separate file(s)");
|
|
449
|
-
console.log(
|
|
450
|
-
'2. Load with media query: <link rel="stylesheet" href="desktop.css" media="(min-width: 768px)">',
|
|
451
|
-
);
|
|
452
|
-
console.log("3. Consider critical CSS inlining for above-the-fold mobile content");
|
|
453
|
-
console.log("");
|
|
454
|
-
|
|
455
|
-
// Business impact estimation
|
|
456
|
-
const fcpImprovement = midRangeTotalBlocking * 0.6;
|
|
457
|
-
console.log("š Estimated business impact:");
|
|
458
|
-
console.log(` ⢠FCP improvement: ~${fcpImprovement.toFixed(0)}ms`);
|
|
459
|
-
console.log(" ⢠100ms FCP improvement ā 1% conversion increase (Google/Deloitte research)");
|
|
460
|
-
console.log(` ⢠Potential conversion lift: ~${(fcpImprovement / 100).toFixed(2)}%`);
|
|
461
|
-
|
|
181
|
+
issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
|
|
462
182
|
console.groupEnd();
|
|
463
183
|
|
|
464
|
-
// Memory impact
|
|
465
|
-
console.group("š§ MEMORY IMPACT");
|
|
466
|
-
|
|
467
|
-
// CSSOM memory estimation
|
|
468
|
-
// Approximately: each CSS rule = ~1KB in memory (including selector, properties, computed values)
|
|
469
|
-
const totalMediaRules = mediaQueryResults.summary.total.mediaQueries;
|
|
470
|
-
const estimatedMemoryKB = totalMediaRules * 1.0; // 1KB per rule
|
|
471
|
-
|
|
472
|
-
console.log(`Estimated CSSOM memory overhead: ~${estimatedMemoryKB.toFixed(1)} KB`);
|
|
473
|
-
console.log(
|
|
474
|
-
`Total unnecessary memory allocation: ~${(unnecessaryBytes / 1024 + estimatedMemoryKB).toFixed(1)} KB`,
|
|
475
|
-
);
|
|
476
|
-
console.log("");
|
|
477
|
-
console.log("š” This memory stays allocated throughout the page lifecycle,");
|
|
478
|
-
console.log(" contributing to memory pressure on low-end devices.");
|
|
479
|
-
|
|
480
|
-
console.groupEnd();
|
|
481
|
-
|
|
482
|
-
// CORS disclaimer if applicable
|
|
483
|
-
if (mediaQueryResults.summary.corsBlocked > 0) {
|
|
484
|
-
console.log("");
|
|
485
|
-
console.log(
|
|
486
|
-
`%cā ļø Note: ${mediaQueryResults.summary.corsBlocked} CSS file(s) blocked by CORS were not analyzed.`,
|
|
487
|
-
"color: #f59e0b; font-weight: bold",
|
|
488
|
-
);
|
|
489
|
-
console.log("The actual performance impact could be higher than shown above.");
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Return structured data
|
|
493
|
-
return {
|
|
494
|
-
unnecessaryBytes,
|
|
495
|
-
unnecessaryCss: mediaQueryResults.summary.total.bytesFormatted,
|
|
496
|
-
totalClasses,
|
|
497
|
-
totalProperties,
|
|
498
|
-
corsBlockedCount: mediaQueryResults.summary.corsBlocked,
|
|
499
|
-
deviceImpact: estimateCSSImpact(mediaQueryResults.summary).deviceImpact,
|
|
500
|
-
estimatedConversionLift: ((midRangeTotalBlocking * 0.6) / 100).toFixed(2) + "%",
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
window.analyzeCSSPerformanceImpact = analyzeCSSPerformanceImpact;
|
|
505
|
-
|
|
506
|
-
// Run with default breakpoint (768px)
|
|
507
|
-
(async () => {
|
|
508
|
-
const result = await analyzeCSSMediaQueries();
|
|
509
|
-
if (!result) {
|
|
510
|
-
return { script: "CSS-Media-Queries-Analysis", status: "error", error: "No analyzable CSS found (CORS blocked or no desktop media queries)" };
|
|
511
|
-
}
|
|
512
184
|
return {
|
|
513
|
-
script:
|
|
185
|
+
script: SCRIPT,
|
|
514
186
|
status: "ok",
|
|
515
|
-
count:
|
|
187
|
+
count: queries.length,
|
|
516
188
|
details: {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
})(),
|
|
189
|
+
minWidth,
|
|
190
|
+
rules: rules.length,
|
|
191
|
+
totalUnique: queries.length,
|
|
192
|
+
uniquenessRatio,
|
|
193
|
+
classes: rules.reduce((sum, r) => sum + r.classes, 0),
|
|
194
|
+
properties: rules.reduce((sum, r) => sum + r.properties, 0),
|
|
195
|
+
bytes: totalBytes,
|
|
196
|
+
bySource: bySource.slice(0, MAX_ITEMS),
|
|
197
|
+
corsBlockedCount,
|
|
537
198
|
},
|
|
538
|
-
items:
|
|
199
|
+
items: queries.slice(0, MAX_ITEMS),
|
|
200
|
+
issues,
|
|
539
201
|
};
|
|
540
|
-
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Run with default breakpoint (768px)
|
|
205
|
+
analyzeCSSMediaQueries();
|
|
541
206
|
|
|
542
207
|
// Or customize the breakpoint:
|
|
543
208
|
// analyzeCSSMediaQueries(1024); // for desktop
|