webperf-snippets 0.4.0 → 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.
Files changed (65) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/snippets/CoreWebVitals/CLS.js +1 -1
  4. package/snippets/CoreWebVitals/INP.js +1 -1
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +15 -1
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +47 -18
  7. package/snippets/CoreWebVitals/LCP-Trail.js +35 -10
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +48 -24
  9. package/snippets/CoreWebVitals/LCP.js +37 -15
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +1 -1
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +1 -1
  12. package/snippets/Interaction/DOM-Size-and-Depth.js +1 -1
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +1 -1
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +1 -1
  15. package/snippets/Interaction/Interactions.js +1 -1
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +1 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +1 -1
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +1 -1
  19. package/snippets/Interaction/Long-Animation-Frames.js +1 -1
  20. package/snippets/Interaction/LongTask.js +1 -1
  21. package/snippets/Interaction/Scroll-Performance.js +1 -1
  22. package/snippets/Loading/Back-Forward-Cache.js +19 -2
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +121 -456
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +41 -20
  25. package/snippets/Loading/Client-Side-Redirect-Detection.js +11 -3
  26. package/snippets/Loading/Compression-Audit.js +10 -2
  27. package/snippets/Loading/Critical-CSS-Detection.js +11 -3
  28. package/snippets/Loading/Event-Processing-Time.js +1 -1
  29. package/snippets/Loading/FCP.js +12 -4
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +12 -4
  31. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  32. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +10 -2
  33. package/snippets/Loading/Find-render-blocking-resources.js +11 -3
  34. package/snippets/Loading/First-And-Third-Party-Script-Info.js +10 -2
  35. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +10 -2
  36. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +1 -1
  37. package/snippets/Loading/Inline-CSS-Info-and-Size.js +1 -1
  38. package/snippets/Loading/Inline-Script-Info-and-Size.js +1 -1
  39. package/snippets/Loading/JS-Execution-Time-Breakdown.js +10 -2
  40. package/snippets/Loading/Prefetch-Resource-Validation.js +19 -5
  41. package/snippets/Loading/Priority-Hints-Audit.js +11 -3
  42. package/snippets/Loading/Resource-Hints-Validation.js +10 -2
  43. package/snippets/Loading/Resource-Hints.js +11 -3
  44. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +1 -1
  45. package/snippets/Loading/Script-Loading.js +10 -2
  46. package/snippets/Loading/Server-Timing-Early-Hints.js +10 -2
  47. package/snippets/Loading/Service-Worker-Analysis.js +10 -2
  48. package/snippets/Loading/Speculation-Rules-Inspector.js +1 -1
  49. package/snippets/Loading/TTFB-Resources.js +11 -3
  50. package/snippets/Loading/TTFB-Sub-Parts.js +27 -1
  51. package/snippets/Loading/TTFB.js +1 -1
  52. package/snippets/Loading/Third-Party-Impact-by-Domain.js +10 -2
  53. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +10 -2
  54. package/snippets/Loading/Webfont-Usage-Analyzer.js +105 -30
  55. package/snippets/Media/Image-Element-Audit.js +19 -2
  56. package/snippets/Media/Oversized-Images.js +28 -3
  57. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +48 -9
  58. package/snippets/Media/Video-Element-Audit.js +16 -10
  59. package/snippets/README.md +1 -1
  60. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +1 -1
  61. package/snippets/SCHEMA.md +33 -14
  62. package/snippets/_shared/helpers.js +59 -0
  63. package/src/interactions.js +27 -0
  64. package/src/runner.js +5 -3
  65. package/src/tracking.js +4 -4
@@ -1,7 +1,24 @@
1
1
  // Back/Forward Cache (bfcache) Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
+ // @shared ownRequestUrl
6
+ // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
7
+ // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
8
+ // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
9
+ // which is how isOwnRequest leaves these requests out of what the page loaded.
10
+ function ownRequestUrl(url) {
11
+ try {
12
+ const parsed = new URL(url, location.href);
13
+ if (!/^https?:$/.test(parsed.protocol)) return url;
14
+ parsed.hash = "webperf-snippets";
15
+ return parsed.href;
16
+ } catch {
17
+ return url;
18
+ }
19
+ }
20
+ // @end-shared ownRequestUrl
21
+
5
22
  // Only what the browser can report is analysed. Several blockers cannot be detected from
6
23
  // JavaScript (unload listeners added with addEventListener, open WebSocket, BroadcastChannel
7
24
  // or IndexedDB connections). After a back navigation, the NotRestoredReasons API reports
@@ -87,7 +104,7 @@
87
104
  // exposes the header; the value can differ from the original navigation.
88
105
  if (/^https?:$/.test(location.protocol)) {
89
106
  try {
90
- const res = await fetch(location.href, { method: 'HEAD', credentials: 'same-origin' });
107
+ const res = await fetch(ownRequestUrl(location.href), { method: 'HEAD', credentials: 'same-origin' });
91
108
  const cacheControl = res.headers.get('cache-control') || '';
92
109
  if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) {
93
110
  issues.push({
@@ -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 inlineMediaQueries = [];
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
- // Check for max-width to exclude (mobile-only queries)
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
- // Helper to count classes and properties in CSS text
41
- function countClassesAndProperties(cssText) {
42
- const classMatches = cssText.match(/\.[a-zA-Z0-9_-]+/g) || [];
43
- const propertyMatches = cssText.match(/[a-z-]+\s*:/g) || [];
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
- // Parse CSS text to find @media rules
68
- function parseMediaQueriesFromCSS(cssText, source, isInline) {
69
- // Improved regex to capture complete @media rules
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
- const mediaText = match[1].trim();
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 isInline = !sheet.href;
89
+ const inline = !sheet.href;
109
90
  const source = sheet.href || `<style> tag #${sheetIndex}`;
110
91
 
111
92
  try {
112
- // Try to access via cssRules first
113
- const rules = sheet.cssRules || sheet.rules;
114
- if (rules) {
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 mediaText = rule.media.mediaText;
118
- if (isBiggerThanBreakpoint(mediaText)) {
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
- // If CORS blocked, try to fetch the CSS
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
- const cssText = await response.text();
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
- const totalBytes = inlineTotalBytes + filesTotalBytes;
163
-
164
- // Display results
165
- console.group(`šŸ“Š CSS Media Queries Analysis (min-width > ${minWidth}px)`);
166
- console.log(`Total @media rules found: ${inlineMediaQueries.length + fileMediaQueries.length}`);
167
- console.log(`Total classes: ${inlineTotalClasses + filesTotalClasses}`);
168
- console.log(`Total properties: ${inlineTotalProperties + filesTotalProperties}`);
169
- console.groupEnd();
170
-
171
- // Mobile Savings Estimate
172
- console.group("šŸ’¾ POTENTIAL MOBILE SAVINGS");
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
- console.groupEnd();
194
-
195
- // External Files Results
196
- console.group("šŸ“ EXTERNAL FILES (.css files)");
197
- console.log(`Media queries: ${fileMediaQueries.length}`);
198
- console.log(`Classes: ${filesTotalClasses}`);
199
- console.log(`Properties: ${filesTotalProperties}`);
200
- console.log(`Total size: ${formatBytes(filesTotalBytes)}`);
201
- if (fileMediaQueries.length === 0) {
202
- console.log(`No external file media queries found for viewports > ${minWidth}px.`);
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
- console.group("āš ļø CORS LIMITATIONS");
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
- return {
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
- // First, run the media queries analysis
315
- const mediaQueryResults = await analyzeCSSMediaQueries(minWidth);
316
- const unnecessaryBytes = mediaQueryResults.summary.total.bytes;
317
-
318
- // If no data was found, show early exit message
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.log("");
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: "CSS-Media-Queries-Analysis",
185
+ script: SCRIPT,
514
186
  status: "ok",
515
- count: result.summary.total.mediaQueries,
187
+ count: queries.length,
516
188
  details: {
517
- total: result.summary.total,
518
- inline: result.summary.inline,
519
- files: result.summary.files,
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
- })(),
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: [...result.details.inline, ...result.details.files],
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