webperf-snippets 0.1.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 +139 -0
- package/package.json +54 -0
- package/snippets/CoreWebVitals/CLS.js +88 -0
- package/snippets/CoreWebVitals/INP.js +432 -0
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
- package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
- package/snippets/CoreWebVitals/LCP.js +135 -0
- package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
- package/snippets/Interaction/Interactions.js +220 -0
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
- package/snippets/Interaction/Long-Animation-Frames.js +298 -0
- package/snippets/Interaction/LongTask.js +203 -0
- package/snippets/Interaction/Scroll-Performance.js +359 -0
- package/snippets/Interaction/WORKFLOWS.md +205 -0
- package/snippets/Loading/Back-Forward-Cache.js +473 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
- package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
- package/snippets/Loading/Content-Visibility.js +352 -0
- package/snippets/Loading/Critical-CSS-Detection.js +329 -0
- package/snippets/Loading/Event-Processing-Time.js +179 -0
- package/snippets/Loading/FCP.js +101 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
- package/snippets/Loading/Find-render-blocking-resources.js +161 -0
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
- package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
- package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
- package/snippets/Loading/Priority-Hints-Audit.js +336 -0
- package/snippets/Loading/Resource-Hints-Validation.js +503 -0
- package/snippets/Loading/Resource-Hints.js +400 -0
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
- package/snippets/Loading/Script-Loading.js +332 -0
- package/snippets/Loading/Service-Worker-Analysis.js +192 -0
- package/snippets/Loading/TTFB-Resources.js +115 -0
- package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
- package/snippets/Loading/TTFB.js +37 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
- package/snippets/Loading/WORKFLOWS.md +163 -0
- package/snippets/Media/Image-Element-Audit.js +350 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
- package/snippets/Media/Video-Element-Audit.js +282 -0
- package/snippets/Media/WORKFLOWS.md +190 -0
- package/snippets/README.md +98 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
- package/snippets/Resources/WORKFLOWS.md +173 -0
- package/src/bin.js +229 -0
- package/src/decision-tree.js +19 -0
- package/src/load-snippet.js +23 -0
- package/src/reporters/human.js +179 -0
- package/src/reporters/json.js +3 -0
- package/src/runner.js +61 -0
- package/src/workflows/audit.js +21 -0
- package/src/workflows/cwv.js +10 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Critical CSS Detection
|
|
2
|
+
// Analyzes render-blocking stylesheets, inline CSS budget, and loading strategy
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(() => {
|
|
6
|
+
const formatBytes = (bytes) => {
|
|
7
|
+
if (bytes === 0) return "0 B";
|
|
8
|
+
const k = 1024;
|
|
9
|
+
const sizes = ["B", "KB", "MB"];
|
|
10
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
11
|
+
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
|
|
15
|
+
|
|
16
|
+
// --- External stylesheets ---
|
|
17
|
+
const linkStylesheets = Array.from(
|
|
18
|
+
document.querySelectorAll('link[rel="stylesheet"]')
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const perfEntries = performance.getEntriesByType("resource");
|
|
22
|
+
|
|
23
|
+
const externalCSSData = linkStylesheets.map((link) => {
|
|
24
|
+
const href = link.href;
|
|
25
|
+
const media = link.getAttribute("media") || "all";
|
|
26
|
+
|
|
27
|
+
const perfEntry = perfEntries.find((e) => e.name === href);
|
|
28
|
+
|
|
29
|
+
// Prefer browser-reported render-blocking status (PerformanceResourceTiming API,
|
|
30
|
+
// Chrome 107+). This correctly handles the media="print" onload="this.media='all'"
|
|
31
|
+
// deferral pattern ā by page load time the media attribute has already changed to
|
|
32
|
+
// "all", so a DOM-only check would produce false positives.
|
|
33
|
+
let isRenderBlocking;
|
|
34
|
+
if (perfEntry && perfEntry.renderBlockingStatus !== undefined) {
|
|
35
|
+
isRenderBlocking = perfEntry.renderBlockingStatus === "blocking";
|
|
36
|
+
} else {
|
|
37
|
+
// Fallback for browsers without renderBlockingStatus support.
|
|
38
|
+
// Note: may produce false positives when the media/onload deferral pattern is used.
|
|
39
|
+
isRenderBlocking =
|
|
40
|
+
media === "all" ||
|
|
41
|
+
media === "" ||
|
|
42
|
+
media === "screen" ||
|
|
43
|
+
(media.toLowerCase().includes("screen") &&
|
|
44
|
+
!media.toLowerCase().includes("print"));
|
|
45
|
+
}
|
|
46
|
+
const transferSize = perfEntry ? perfEntry.transferSize : 0;
|
|
47
|
+
const decodedSize = perfEntry ? perfEntry.decodedBodySize : 0;
|
|
48
|
+
|
|
49
|
+
const preloadLink = document.querySelector(
|
|
50
|
+
`link[rel="preload"][as="style"][href="${href}"]`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
let ruleCount = 0;
|
|
54
|
+
let corsBlocked = false;
|
|
55
|
+
try {
|
|
56
|
+
const sheet = Array.from(document.styleSheets).find(
|
|
57
|
+
(s) => s.href === href
|
|
58
|
+
);
|
|
59
|
+
if (sheet && sheet.cssRules) ruleCount = sheet.cssRules.length;
|
|
60
|
+
} catch {
|
|
61
|
+
corsBlocked = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
filename: href.split("/").pop().split("?")[0] || href,
|
|
66
|
+
href,
|
|
67
|
+
media,
|
|
68
|
+
isRenderBlocking,
|
|
69
|
+
transferSize,
|
|
70
|
+
decodedSize,
|
|
71
|
+
ruleCount,
|
|
72
|
+
corsBlocked,
|
|
73
|
+
preloaded: preloadLink !== null,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- Inline styles ---
|
|
78
|
+
const inlineStyles = Array.from(document.querySelectorAll("style")).map(
|
|
79
|
+
(style, i) => {
|
|
80
|
+
const content = style.innerHTML;
|
|
81
|
+
const size = new Blob([content]).size;
|
|
82
|
+
const ruleCount = (content.match(/\{[^}]*\}/g) || []).length;
|
|
83
|
+
return {
|
|
84
|
+
index: i + 1,
|
|
85
|
+
size,
|
|
86
|
+
ruleCount,
|
|
87
|
+
inHead: style.closest("head") !== null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// --- Totals ---
|
|
93
|
+
const renderBlockingCSS = externalCSSData.filter((s) => s.isRenderBlocking);
|
|
94
|
+
const nonBlockingCSS = externalCSSData.filter((s) => !s.isRenderBlocking);
|
|
95
|
+
const totalInlineBytes = inlineStyles.reduce((sum, s) => sum + s.size, 0);
|
|
96
|
+
const totalRenderBlockingDecoded = renderBlockingCSS.reduce(
|
|
97
|
+
(sum, s) => sum + s.decodedSize,
|
|
98
|
+
0
|
|
99
|
+
);
|
|
100
|
+
const totalExternalTransfer = externalCSSData.reduce(
|
|
101
|
+
(sum, s) => sum + s.transferSize,
|
|
102
|
+
0
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// --- Output ---
|
|
106
|
+
console.group(
|
|
107
|
+
"%cšÆ Critical CSS Detection",
|
|
108
|
+
"font-weight: bold; font-size: 14px;"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
console.log("");
|
|
112
|
+
console.log("%cPage CSS Summary:", "font-weight: bold;");
|
|
113
|
+
console.log(` External stylesheets: ${externalCSSData.length}`);
|
|
114
|
+
console.log(` Render-blocking: ${renderBlockingCSS.length}`);
|
|
115
|
+
console.log(` Non-blocking (media query): ${nonBlockingCSS.length}`);
|
|
116
|
+
console.log(` Inline <style> blocks: ${inlineStyles.length}`);
|
|
117
|
+
console.log(` Total external CSS (wire): ${formatBytes(totalExternalTransfer)}`);
|
|
118
|
+
console.log(` Total inline CSS: ${formatBytes(totalInlineBytes)}`);
|
|
119
|
+
|
|
120
|
+
// Render-blocking stylesheets
|
|
121
|
+
if (renderBlockingCSS.length > 0) {
|
|
122
|
+
console.log("");
|
|
123
|
+
console.group(
|
|
124
|
+
`%cš« Render-Blocking Stylesheets (${renderBlockingCSS.length})`,
|
|
125
|
+
"color: #ef4444; font-weight: bold;"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
console.table(
|
|
129
|
+
renderBlockingCSS.map((s) => ({
|
|
130
|
+
File: s.filename,
|
|
131
|
+
Media: s.media,
|
|
132
|
+
"Transfer Size": formatBytes(s.transferSize),
|
|
133
|
+
"Decoded Size": formatBytes(s.decodedSize),
|
|
134
|
+
Rules: s.corsBlocked ? "CORS blocked" : s.ruleCount,
|
|
135
|
+
Preloaded: s.preloaded ? "ā
Yes" : "ā No",
|
|
136
|
+
}))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const budgetPct = Math.min(
|
|
140
|
+
(totalRenderBlockingDecoded / criticalBudget) * 100,
|
|
141
|
+
200
|
|
142
|
+
);
|
|
143
|
+
const barWidth = Math.min(Math.round(budgetPct / 10), 20);
|
|
144
|
+
const bar = "ā".repeat(barWidth) + "ā".repeat(20 - barWidth);
|
|
145
|
+
console.log(
|
|
146
|
+
`\n Total render-blocking: ${formatBytes(totalRenderBlockingDecoded)}`
|
|
147
|
+
);
|
|
148
|
+
console.log(` vs. 14 KB budget: [${bar}] ${budgetPct.toFixed(0)}%`);
|
|
149
|
+
|
|
150
|
+
console.groupEnd();
|
|
151
|
+
} else {
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log(
|
|
154
|
+
"%cā
No render-blocking stylesheets detected.",
|
|
155
|
+
"color: #22c55e; font-weight: bold;"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Non-blocking stylesheets
|
|
160
|
+
if (nonBlockingCSS.length > 0) {
|
|
161
|
+
console.log("");
|
|
162
|
+
console.group(
|
|
163
|
+
`%cā
Non-Blocking Stylesheets (${nonBlockingCSS.length})`,
|
|
164
|
+
"color: #22c55e; font-weight: bold;"
|
|
165
|
+
);
|
|
166
|
+
console.table(
|
|
167
|
+
nonBlockingCSS.map((s) => ({
|
|
168
|
+
File: s.filename,
|
|
169
|
+
Media: s.media,
|
|
170
|
+
"Transfer Size": formatBytes(s.transferSize),
|
|
171
|
+
Rules: s.corsBlocked ? "CORS blocked" : s.ruleCount,
|
|
172
|
+
}))
|
|
173
|
+
);
|
|
174
|
+
console.groupEnd();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Inline styles
|
|
178
|
+
if (inlineStyles.length > 0) {
|
|
179
|
+
console.log("");
|
|
180
|
+
const inlineBudgetPct = ((totalInlineBytes / criticalBudget) * 100).toFixed(0);
|
|
181
|
+
const inlineRating =
|
|
182
|
+
totalInlineBytes <= criticalBudget * 0.5
|
|
183
|
+
? { label: "Good", color: "#22c55e" }
|
|
184
|
+
: totalInlineBytes <= criticalBudget
|
|
185
|
+
? { label: "Acceptable", color: "#f59e0b" }
|
|
186
|
+
: { label: "Too Large", color: "#ef4444" };
|
|
187
|
+
|
|
188
|
+
console.group(
|
|
189
|
+
`%cš Inline CSS (${inlineStyles.length} block${inlineStyles.length > 1 ? "s" : ""})`,
|
|
190
|
+
"color: #3b82f6; font-weight: bold;"
|
|
191
|
+
);
|
|
192
|
+
console.log(` Total size: ${formatBytes(totalInlineBytes)}`);
|
|
193
|
+
console.log(` Budget used: ${inlineBudgetPct}% of 14 KB`);
|
|
194
|
+
console.log(
|
|
195
|
+
` Rating: %c${inlineRating.label}`,
|
|
196
|
+
`color: ${inlineRating.color}; font-weight: bold;`
|
|
197
|
+
);
|
|
198
|
+
console.table(
|
|
199
|
+
inlineStyles.map((s) => ({
|
|
200
|
+
"#": s.index,
|
|
201
|
+
Location: s.inHead ? "head" : "body",
|
|
202
|
+
Size: formatBytes(s.size),
|
|
203
|
+
"Rules (~)": s.ruleCount,
|
|
204
|
+
}))
|
|
205
|
+
);
|
|
206
|
+
console.groupEnd();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Issues
|
|
210
|
+
const issues = [];
|
|
211
|
+
|
|
212
|
+
if (renderBlockingCSS.length > 0 && inlineStyles.length === 0) {
|
|
213
|
+
issues.push({
|
|
214
|
+
severity: "high",
|
|
215
|
+
message: "No critical CSS inlined ā all CSS loads via render-blocking requests",
|
|
216
|
+
suggestion:
|
|
217
|
+
'Extract above-the-fold styles into a <style> tag in <head> and defer the rest',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (totalRenderBlockingDecoded > criticalBudget) {
|
|
222
|
+
issues.push({
|
|
223
|
+
severity: "high",
|
|
224
|
+
message: `Render-blocking CSS (${formatBytes(totalRenderBlockingDecoded)}) exceeds the 14 KB critical budget`,
|
|
225
|
+
suggestion:
|
|
226
|
+
"Inline critical CSS and defer non-critical stylesheets using the media/onload pattern",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const largeUnpreloaded = renderBlockingCSS.filter(
|
|
231
|
+
(s) => !s.preloaded && s.transferSize > 10 * 1024
|
|
232
|
+
);
|
|
233
|
+
if (largeUnpreloaded.length > 0) {
|
|
234
|
+
issues.push({
|
|
235
|
+
severity: "medium",
|
|
236
|
+
message: `${largeUnpreloaded.length} large render-blocking stylesheet(s) without preload`,
|
|
237
|
+
details: largeUnpreloaded.map(
|
|
238
|
+
(s) => `⢠${s.filename} (${formatBytes(s.transferSize)})`
|
|
239
|
+
),
|
|
240
|
+
suggestion: 'Add <link rel="preload" as="style" href="..."> to start download earlier',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (totalInlineBytes > criticalBudget) {
|
|
245
|
+
issues.push({
|
|
246
|
+
severity: "high",
|
|
247
|
+
message: `Inline CSS (${formatBytes(totalInlineBytes)}) exceeds the 14 KB critical budget`,
|
|
248
|
+
suggestion:
|
|
249
|
+
"Keep inline critical CSS under 14 KB. Move non-critical styles to an external deferred file.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const bodyInlineStyles = inlineStyles.filter((s) => !s.inHead);
|
|
254
|
+
if (bodyInlineStyles.length > 0) {
|
|
255
|
+
issues.push({
|
|
256
|
+
severity: "medium",
|
|
257
|
+
message: `${bodyInlineStyles.length} inline style block(s) found in <body>`,
|
|
258
|
+
suggestion:
|
|
259
|
+
"Move critical styles to <head> to avoid potential render-blocking reflows",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (issues.length > 0) {
|
|
264
|
+
console.log("");
|
|
265
|
+
console.group("%cā ļø Issues Found", "color: #f59e0b; font-weight: bold;");
|
|
266
|
+
issues.forEach((issue) => {
|
|
267
|
+
const icon =
|
|
268
|
+
issue.severity === "high" ? "š“" : issue.severity === "medium" ? "š”" : "šµ";
|
|
269
|
+
console.log(`\n${icon} ${issue.message}`);
|
|
270
|
+
if (issue.details) issue.details.forEach((d) => console.log(` ${d}`));
|
|
271
|
+
console.log(` ā ${issue.suggestion}`);
|
|
272
|
+
});
|
|
273
|
+
console.groupEnd();
|
|
274
|
+
} else {
|
|
275
|
+
console.log("");
|
|
276
|
+
console.log(
|
|
277
|
+
"%cā
No critical CSS issues detected.",
|
|
278
|
+
"color: #22c55e; font-weight: bold;"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Best practices
|
|
283
|
+
console.log("");
|
|
284
|
+
console.group(
|
|
285
|
+
"%cš Critical CSS Best Practices",
|
|
286
|
+
"color: #3b82f6; font-weight: bold;"
|
|
287
|
+
);
|
|
288
|
+
console.log("");
|
|
289
|
+
console.log("⢠Inline critical above-the-fold CSS in <head> (target: < 14 KB)");
|
|
290
|
+
console.log("⢠Defer non-critical CSS:");
|
|
291
|
+
console.log(
|
|
292
|
+
' <link rel="stylesheet" href="styles.css" media="print" onload="this.media=\'all\'">'
|
|
293
|
+
);
|
|
294
|
+
console.log('⢠Preload critical external CSS: <link rel="preload" as="style" href="...">');
|
|
295
|
+
console.log("⢠Use Chrome DevTools Coverage tab (Ctrl+Shift+P ā Coverage) to find unused CSS");
|
|
296
|
+
console.log("⢠Automate critical CSS extraction with tools like critical or Critters");
|
|
297
|
+
console.groupEnd();
|
|
298
|
+
|
|
299
|
+
console.groupEnd();
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
script: "Critical-CSS-Detection",
|
|
303
|
+
status: "ok",
|
|
304
|
+
count: externalCSSData.length,
|
|
305
|
+
details: {
|
|
306
|
+
renderBlockingCount: renderBlockingCSS.length,
|
|
307
|
+
nonBlockingCount: nonBlockingCSS.length,
|
|
308
|
+
inlineCount: inlineStyles.length,
|
|
309
|
+
totalExternalTransferBytes: totalExternalTransfer,
|
|
310
|
+
renderBlockingDecodedBytes: totalRenderBlockingDecoded,
|
|
311
|
+
inlineBytes: totalInlineBytes,
|
|
312
|
+
criticalBudgetBytes: criticalBudget,
|
|
313
|
+
},
|
|
314
|
+
items: externalCSSData.map(s => ({
|
|
315
|
+
filename: s.filename,
|
|
316
|
+
media: s.media,
|
|
317
|
+
isRenderBlocking: s.isRenderBlocking,
|
|
318
|
+
transferBytes: s.transferSize,
|
|
319
|
+
decodedBytes: s.decodedSize,
|
|
320
|
+
ruleCount: s.ruleCount,
|
|
321
|
+
preloaded: s.preloaded,
|
|
322
|
+
corsBlocked: s.corsBlocked,
|
|
323
|
+
})),
|
|
324
|
+
issues: issues.map(i => ({
|
|
325
|
+
severity: i.severity === 'high' ? 'error' : 'warning',
|
|
326
|
+
message: i.message,
|
|
327
|
+
})),
|
|
328
|
+
};
|
|
329
|
+
})();
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Event Processing Time - Analyze page load phases
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const phases = [
|
|
6
|
+
{ name: "Redirect", start: "redirectStart", end: "redirectEnd", icon: "š" },
|
|
7
|
+
{ name: "DNS Lookup", start: "domainLookupStart", end: "domainLookupEnd", icon: "š" },
|
|
8
|
+
{ name: "TCP Connection", start: "connectStart", end: "connectEnd", icon: "š" },
|
|
9
|
+
{ name: "SSL/TLS", start: "secureConnectionStart", end: "connectEnd", icon: "š" },
|
|
10
|
+
{ name: "Request", start: "requestStart", end: "responseStart", icon: "š¤" },
|
|
11
|
+
{ name: "Response", start: "responseStart", end: "responseEnd", icon: "š„" },
|
|
12
|
+
{ name: "DOM Processing", start: "responseEnd", end: "domContentLoadedEventStart", icon: "šļø" },
|
|
13
|
+
{ name: "DOMContentLoaded", start: "domContentLoadedEventStart", end: "domContentLoadedEventEnd", icon: "š" },
|
|
14
|
+
{ name: "Resource Loading", start: "domContentLoadedEventEnd", end: "loadEventStart", icon: "š¦" },
|
|
15
|
+
{ name: "Load Event", start: "loadEventStart", end: "loadEventEnd", icon: "ā
" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
new PerformanceObserver((list) => {
|
|
19
|
+
const [entry] = list.getEntries();
|
|
20
|
+
|
|
21
|
+
const formatMs = (ms) => ms.toFixed(2) + " ms";
|
|
22
|
+
const formatBar = (ms, total) => {
|
|
23
|
+
const pct = total > 0 ? (ms / total) * 100 : 0;
|
|
24
|
+
const width = Math.min(Math.round(pct / 2.5), 40);
|
|
25
|
+
return "ā".repeat(width) + "ā".repeat(Math.max(0, 20 - width));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Calculate all phase durations
|
|
29
|
+
const phaseData = phases
|
|
30
|
+
.map((phase) => {
|
|
31
|
+
const start = entry[phase.start] || 0;
|
|
32
|
+
const end = entry[phase.end] || 0;
|
|
33
|
+
let duration = end - start;
|
|
34
|
+
|
|
35
|
+
// Handle SSL which shares end with TCP
|
|
36
|
+
if (phase.name === "SSL/TLS" && entry.secureConnectionStart > 0) {
|
|
37
|
+
duration = entry.connectEnd - entry.secureConnectionStart;
|
|
38
|
+
} else if (phase.name === "TCP Connection" && entry.secureConnectionStart > 0) {
|
|
39
|
+
duration = entry.secureConnectionStart - entry.connectStart;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...phase,
|
|
44
|
+
start,
|
|
45
|
+
end,
|
|
46
|
+
duration: Math.max(0, duration),
|
|
47
|
+
};
|
|
48
|
+
})
|
|
49
|
+
.filter((p) => p.duration > 0 || p.name === "Redirect");
|
|
50
|
+
|
|
51
|
+
const totalTime = entry.loadEventEnd - entry.startTime;
|
|
52
|
+
const networkTime = (entry.responseEnd || 0) - entry.startTime;
|
|
53
|
+
const processingTime = totalTime - networkTime;
|
|
54
|
+
|
|
55
|
+
// Rating based on total load time
|
|
56
|
+
let rating, color;
|
|
57
|
+
if (totalTime <= 2500) {
|
|
58
|
+
rating = "Fast";
|
|
59
|
+
color = "#22c55e";
|
|
60
|
+
} else if (totalTime <= 4000) {
|
|
61
|
+
rating = "Moderate";
|
|
62
|
+
color = "#f59e0b";
|
|
63
|
+
} else {
|
|
64
|
+
rating = "Slow";
|
|
65
|
+
color = "#ef4444";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.group(
|
|
69
|
+
`%cā±ļø Page Load: ${totalTime.toFixed(0)}ms (${rating})`,
|
|
70
|
+
`color: ${color}; font-weight: bold; font-size: 14px;`
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Summary
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log("%cSummary:", "font-weight: bold;");
|
|
76
|
+
console.log(` Network time: ${networkTime.toFixed(0)}ms (${((networkTime / totalTime) * 100).toFixed(0)}%)`);
|
|
77
|
+
console.log(` Processing time: ${processingTime.toFixed(0)}ms (${((processingTime / totalTime) * 100).toFixed(0)}%)`);
|
|
78
|
+
console.log(` Total: ${totalTime.toFixed(0)}ms`);
|
|
79
|
+
|
|
80
|
+
// Timeline visualization
|
|
81
|
+
console.log("");
|
|
82
|
+
console.log("%cTimeline:", "font-weight: bold;");
|
|
83
|
+
|
|
84
|
+
phaseData.forEach(({ name, duration, icon }) => {
|
|
85
|
+
const pct = (duration / totalTime) * 100;
|
|
86
|
+
const bar = formatBar(duration, totalTime);
|
|
87
|
+
const durationStr = formatMs(duration).padStart(12);
|
|
88
|
+
const pctStr = `${pct.toFixed(1)}%`.padStart(7);
|
|
89
|
+
|
|
90
|
+
// Color code long phases
|
|
91
|
+
let phaseColor = "";
|
|
92
|
+
if (duration > totalTime * 0.3) {
|
|
93
|
+
phaseColor = "color: #ef4444;"; // Red for >30%
|
|
94
|
+
} else if (duration > totalTime * 0.15) {
|
|
95
|
+
phaseColor = "color: #f59e0b;"; // Orange for >15%
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(
|
|
99
|
+
`%c${icon} ${name.padEnd(18)} ${durationStr} ${pctStr} ${bar}`,
|
|
100
|
+
phaseColor
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Key milestones
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log("%cKey Milestones:", "font-weight: bold;");
|
|
107
|
+
console.log(` TTFB (Time to First Byte): ${entry.responseStart.toFixed(0)}ms`);
|
|
108
|
+
console.log(` DOMContentLoaded: ${entry.domContentLoadedEventEnd.toFixed(0)}ms`);
|
|
109
|
+
console.log(` Load Complete: ${entry.loadEventEnd.toFixed(0)}ms`);
|
|
110
|
+
|
|
111
|
+
// Find bottleneck
|
|
112
|
+
const longestPhase = phaseData.reduce((a, b) =>
|
|
113
|
+
a.duration > b.duration ? a : b
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (longestPhase.duration > totalTime * 0.25) {
|
|
117
|
+
console.log("");
|
|
118
|
+
console.log("%cš” Bottleneck Detected:", "color: #3b82f6; font-weight: bold;");
|
|
119
|
+
console.log(
|
|
120
|
+
` ${longestPhase.name} takes ${((longestPhase.duration / totalTime) * 100).toFixed(0)}% of total load time`
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const tips = {
|
|
124
|
+
"Redirect": "Minimize redirects. Update links to final URLs.",
|
|
125
|
+
"DNS Lookup": "Use DNS prefetching (<link rel='dns-prefetch'>). Consider faster DNS.",
|
|
126
|
+
"TCP Connection": "Enable HTTP/2 or HTTP/3. Use connection preconnect.",
|
|
127
|
+
"SSL/TLS": "Enable TLS 1.3. Optimize certificate chain. Use session resumption.",
|
|
128
|
+
"Request": "Optimize server response time. Add caching headers.",
|
|
129
|
+
"Response": "Enable compression. Reduce HTML size. Use streaming.",
|
|
130
|
+
"DOM Processing": "Simplify HTML structure. Defer non-critical scripts.",
|
|
131
|
+
"DOMContentLoaded": "Reduce synchronous JavaScript. Use defer/async.",
|
|
132
|
+
"Resource Loading": "Lazy load images. Prioritize critical resources.",
|
|
133
|
+
"Load Event": "Reduce page weight. Optimize resource loading order.",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
console.log(` Tip: ${tips[longestPhase.name] || "Investigate this phase."}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.groupEnd();
|
|
140
|
+
}).observe({ type: "navigation", buffered: true });
|
|
141
|
+
|
|
142
|
+
// Synchronous return for agent
|
|
143
|
+
const [navSync] = performance.getEntriesByType("navigation");
|
|
144
|
+
if (!navSync) return { script: "Event-Processing-Time", status: "error", error: "No navigation entry" };
|
|
145
|
+
const totalTimeSync = navSync.loadEventEnd - navSync.startTime;
|
|
146
|
+
const networkTimeSync = (navSync.responseEnd || 0) - navSync.startTime;
|
|
147
|
+
const ratingSync = totalTimeSync <= 2500 ? "good" : totalTimeSync <= 4000 ? "needs-improvement" : "poor";
|
|
148
|
+
const phasesSync = [
|
|
149
|
+
{ key: "redirect", duration: Math.max(0, navSync.redirectEnd - navSync.redirectStart) },
|
|
150
|
+
{ key: "dnsLookup", duration: Math.max(0, navSync.domainLookupEnd - navSync.domainLookupStart) },
|
|
151
|
+
{ key: "tcpConnection", duration: Math.max(0, (navSync.secureConnectionStart > 0 ? navSync.secureConnectionStart : navSync.connectEnd) - navSync.connectStart) },
|
|
152
|
+
{ key: "sslTls", duration: navSync.secureConnectionStart > 0 ? Math.max(0, navSync.connectEnd - navSync.secureConnectionStart) : 0 },
|
|
153
|
+
{ key: "request", duration: Math.max(0, navSync.responseStart - navSync.requestStart) },
|
|
154
|
+
{ key: "response", duration: Math.max(0, navSync.responseEnd - navSync.responseStart) },
|
|
155
|
+
{ key: "domProcessing", duration: Math.max(0, navSync.domContentLoadedEventStart - navSync.responseEnd) },
|
|
156
|
+
{ key: "domContentLoaded", duration: Math.max(0, navSync.domContentLoadedEventEnd - navSync.domContentLoadedEventStart) },
|
|
157
|
+
{ key: "resourceLoading", duration: Math.max(0, navSync.loadEventStart - navSync.domContentLoadedEventEnd) },
|
|
158
|
+
{ key: "loadEvent", duration: Math.max(0, navSync.loadEventEnd - navSync.loadEventStart) },
|
|
159
|
+
];
|
|
160
|
+
const nonZeroPhases = phasesSync.filter((p) => p.duration > 0);
|
|
161
|
+
const slowestPhase = nonZeroPhases.length > 0 ? nonZeroPhases.reduce((a, b) => a.duration > b.duration ? a : b) : phasesSync[0];
|
|
162
|
+
return {
|
|
163
|
+
script: "Event-Processing-Time",
|
|
164
|
+
status: "ok",
|
|
165
|
+
value: Math.round(totalTimeSync),
|
|
166
|
+
unit: "ms",
|
|
167
|
+
rating: ratingSync,
|
|
168
|
+
thresholds: { good: 2500, needsImprovement: 4000 },
|
|
169
|
+
details: {
|
|
170
|
+
networkTimeMs: Math.round(networkTimeSync),
|
|
171
|
+
processingTimeMs: Math.round(totalTimeSync - networkTimeSync),
|
|
172
|
+
ttfbMs: Math.round(navSync.responseStart),
|
|
173
|
+
domContentLoadedMs: Math.round(navSync.domContentLoadedEventEnd),
|
|
174
|
+
loadCompleteMs: Math.round(navSync.loadEventEnd),
|
|
175
|
+
phases: Object.fromEntries(phasesSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
|
|
176
|
+
slowestPhase: slowestPhase.key,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
})();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// FCP Analysis
|
|
2
|
+
// First Contentful Paint with render-blocking phase breakdown
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(() => {
|
|
6
|
+
const valueToRating = (ms) =>
|
|
7
|
+
ms <= 1800 ? "good" : ms <= 3000 ? "needs-improvement" : "poor";
|
|
8
|
+
|
|
9
|
+
const RATING = {
|
|
10
|
+
good: { icon: "š¢", color: "#0CCE6A" },
|
|
11
|
+
"needs-improvement": { icon: "š”", color: "#FFA400" },
|
|
12
|
+
poor: { icon: "š“", color: "#FF4E42" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const ms = (val) => `${Math.round(val)}ms`;
|
|
16
|
+
|
|
17
|
+
new PerformanceObserver((list) => {
|
|
18
|
+
const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
|
|
19
|
+
if (!fcpEntry) return;
|
|
20
|
+
|
|
21
|
+
const fcpTime = fcpEntry.startTime;
|
|
22
|
+
const rating = valueToRating(fcpTime);
|
|
23
|
+
const { icon, color } = RATING[rating];
|
|
24
|
+
|
|
25
|
+
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
26
|
+
const ttfb = navEntry?.responseStart ?? 0;
|
|
27
|
+
|
|
28
|
+
// Render-blocking resources (Chrome 107+, requires renderBlockingStatus API)
|
|
29
|
+
const resources = performance.getEntriesByType("resource");
|
|
30
|
+
const blockingResources = resources.filter(
|
|
31
|
+
(r) => r.renderBlockingStatus === "blocking"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const lastBlockingEnd = blockingResources.length
|
|
35
|
+
? Math.max(...blockingResources.map((r) => r.responseEnd))
|
|
36
|
+
: ttfb;
|
|
37
|
+
|
|
38
|
+
console.group(
|
|
39
|
+
`%cFCP: ${icon} ${(fcpTime / 1000).toFixed(2)}s (${rating})`,
|
|
40
|
+
`color: ${color}; font-weight: bold; font-size: 14px;`
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Phase breakdown
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log("%cPhase Breakdown:", "font-weight: bold;");
|
|
46
|
+
console.log(` TTFB: ${ms(ttfb)}`);
|
|
47
|
+
if (blockingResources.length > 0) {
|
|
48
|
+
console.log(
|
|
49
|
+
` Render-blocking load: ${ms(Math.max(0, lastBlockingEnd - ttfb))}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
console.log(
|
|
53
|
+
` Render delay: ${ms(Math.max(0, fcpTime - lastBlockingEnd))}`
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Render-blocking summary
|
|
57
|
+
console.log("");
|
|
58
|
+
if (blockingResources.length > 0) {
|
|
59
|
+
const totalBlockingTime = Math.max(0, lastBlockingEnd - ttfb);
|
|
60
|
+
console.log(
|
|
61
|
+
`%cā ${blockingResources.length} render-blocking resource(s) ā adding ${ms(totalBlockingTime)} before paint`,
|
|
62
|
+
"color: #FFA400; font-weight: bold;"
|
|
63
|
+
);
|
|
64
|
+
blockingResources
|
|
65
|
+
.sort((a, b) => b.responseEnd - a.responseEnd)
|
|
66
|
+
.forEach((r) => {
|
|
67
|
+
const filename = r.name.split("/").pop().split("?")[0] || r.name;
|
|
68
|
+
const type = r.initiatorType === "link" ? "CSS" : "JS";
|
|
69
|
+
console.log(` [${type}] ${ms(r.duration)} ${filename}`);
|
|
70
|
+
});
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(
|
|
73
|
+
"%cā Find Render-Blocking Resources: %chttps://webperf-snippets.nucliweb.net/Loading/Find-render-blocking-resources",
|
|
74
|
+
"color: #3b82f6;",
|
|
75
|
+
"color: #3b82f6; text-decoration: underline;"
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
console.log(
|
|
79
|
+
"%cā No render-blocking resources detected",
|
|
80
|
+
"color: #22c55e;"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.groupEnd();
|
|
85
|
+
}).observe({ type: "paint", buffered: true });
|
|
86
|
+
|
|
87
|
+
// Synchronous return for agent
|
|
88
|
+
const fcpEntrySync = performance.getEntriesByName("first-contentful-paint")[0];
|
|
89
|
+
if (!fcpEntrySync) return { script: "FCP", status: "error", error: "No FCP entry yet" };
|
|
90
|
+
const fcpTimeSync = fcpEntrySync.startTime;
|
|
91
|
+
const ratingSync = valueToRating(fcpTimeSync);
|
|
92
|
+
return {
|
|
93
|
+
script: "FCP",
|
|
94
|
+
status: "ok",
|
|
95
|
+
metric: "FCP",
|
|
96
|
+
value: Math.round(fcpTimeSync),
|
|
97
|
+
unit: "ms",
|
|
98
|
+
rating: ratingSync,
|
|
99
|
+
thresholds: { good: 1800, needsImprovement: 3000 },
|
|
100
|
+
};
|
|
101
|
+
})();
|