webperf-snippets 1.0.0 → 1.2.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 +96 -17
- package/package.json +7 -2
- package/snippets/CoreWebVitals/INP.js +134 -62
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +27 -4
- package/snippets/CoreWebVitals/LCP-Subparts.js +29 -2
- package/snippets/CoreWebVitals/WORKFLOWS.md +2 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +87 -36
- package/snippets/Interaction/Interactions.js +90 -38
- package/snippets/Interaction/Long-Animation-Frames.js +20 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +2 -1
- package/snippets/Loading/FCP.js +34 -7
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +51 -11
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +2 -1
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +2 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +26 -6
- package/snippets/Loading/Script-Loading.js +15 -3
- package/snippets/Loading/TTFB-Resources.js +2 -1
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +27 -5
- package/snippets/SCHEMA.md +76 -30
- package/snippets/_shared/helpers.js +72 -0
- package/src/bin.js +64 -24
- package/src/browser.js +22 -0
- package/src/crux.js +5 -3
- package/src/headers.js +35 -0
- package/src/mcp/documents.js +111 -0
- package/src/mcp/prompts.js +101 -0
- package/src/mcp/server.js +382 -0
- package/src/mcp/sessions.js +91 -0
- package/src/mcp/stdio.js +10 -0
- package/src/own-domains.js +16 -0
- package/src/reporters/human.js +8 -2
- package/src/reporters/markdown.js +3 -1
- package/src/runner.js +50 -12
- package/src/snippet-names.js +10 -1
- package/src/throttling.js +43 -0
- package/src/workflows/index.js +12 -0
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
|
|
6
|
+
console.warn("⚠️ event entries are not supported in this browser.");
|
|
7
|
+
return { script: "Input-Latency-Breakdown", status: "unsupported", error: "event entries not supported in this browser" };
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
11
|
|
|
7
12
|
const valueToRating = (score) =>
|
|
@@ -19,34 +24,81 @@
|
|
|
19
24
|
poor: "🔴",
|
|
20
25
|
};
|
|
21
26
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
);
|
|
27
|
+
// @shared createInteractionTracker
|
|
28
|
+
// Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
|
|
29
|
+
// interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
|
|
30
|
+
// interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
|
|
31
|
+
// durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
|
|
32
|
+
// down, has its own timing. It is reported under the event whose handler ran longest, or under the
|
|
33
|
+
// click or keydown when no handler took a millisecond more than theirs.
|
|
34
|
+
function createInteractionTracker() {
|
|
35
|
+
const byId = new Map();
|
|
36
|
+
const processing = (e) => e.processingEnd - e.processingStart;
|
|
37
|
+
const measure = (id, entries) => {
|
|
38
|
+
const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
|
|
39
|
+
const paint = longest.startTime + longest.duration;
|
|
40
|
+
const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
|
|
41
|
+
const startTime = Math.min(...frame.map((e) => e.startTime));
|
|
42
|
+
const processingStart = Math.min(...frame.map((e) => e.processingStart));
|
|
43
|
+
const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
|
|
44
|
+
const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
|
|
45
|
+
const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
|
|
46
|
+
const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
|
|
47
|
+
return {
|
|
48
|
+
interactionId: id,
|
|
49
|
+
name: reported.name,
|
|
50
|
+
target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
|
|
51
|
+
startTime,
|
|
52
|
+
duration: longest.duration,
|
|
53
|
+
phases: {
|
|
54
|
+
inputDelay: processingStart - startTime,
|
|
55
|
+
processingTime: processingEnd - processingStart,
|
|
56
|
+
presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
// Adds the entries of one observer callback and returns the interactions they changed
|
|
62
|
+
add(entries) {
|
|
63
|
+
const changed = new Set();
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
if (!e.interactionId) continue;
|
|
66
|
+
const record = byId.get(e.interactionId) ?? { entries: [] };
|
|
67
|
+
record.entries.push({
|
|
68
|
+
name: e.name,
|
|
69
|
+
target: e.target,
|
|
70
|
+
startTime: e.startTime,
|
|
71
|
+
duration: e.duration,
|
|
72
|
+
processingStart: e.processingStart,
|
|
73
|
+
processingEnd: e.processingEnd,
|
|
74
|
+
});
|
|
75
|
+
byId.set(e.interactionId, record);
|
|
76
|
+
changed.add(e.interactionId);
|
|
77
|
+
}
|
|
78
|
+
return [...changed].map((id) => {
|
|
79
|
+
const record = byId.get(id);
|
|
80
|
+
record.summary = measure(id, record.entries);
|
|
81
|
+
return record.summary;
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
list() {
|
|
85
|
+
return [...byId.values()].map((record) => record.summary);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// @end-shared createInteractionTracker
|
|
39
90
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
4,
|
|
45
|
-
entry.startTime + entry.duration - entry.processingEnd
|
|
46
|
-
);
|
|
91
|
+
// One record per interaction, even when its entries arrive in separate callbacks
|
|
92
|
+
const tracker = createInteractionTracker();
|
|
93
|
+
const observer = new PerformanceObserver((list) => tracker.add(list.getEntries()));
|
|
94
|
+
observer.observe({ type: "event", durationThreshold: 0, buffered: true });
|
|
47
95
|
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
// Interactions grouped by event type: "click", "keydown", "pointerdown", etc.
|
|
97
|
+
const groupByEventType = () => {
|
|
98
|
+
const byEventType = {};
|
|
99
|
+
for (const { name, duration, phases } of tracker.list()) {
|
|
100
|
+
if (!byEventType[name]) {
|
|
101
|
+
byEventType[name] = {
|
|
50
102
|
count: 0,
|
|
51
103
|
durations: [],
|
|
52
104
|
inputDelays: [],
|
|
@@ -54,17 +106,15 @@
|
|
|
54
106
|
presentationDelays: [],
|
|
55
107
|
};
|
|
56
108
|
}
|
|
57
|
-
|
|
58
|
-
const bucket = byEventType[eventType];
|
|
109
|
+
const bucket = byEventType[name];
|
|
59
110
|
bucket.count++;
|
|
60
|
-
bucket.durations.push(
|
|
61
|
-
bucket.inputDelays.push(inputDelay);
|
|
62
|
-
bucket.processingTimes.push(processingTime);
|
|
63
|
-
bucket.presentationDelays.push(presentationDelay);
|
|
111
|
+
bucket.durations.push(duration);
|
|
112
|
+
bucket.inputDelays.push(phases.inputDelay);
|
|
113
|
+
bucket.processingTimes.push(phases.processingTime);
|
|
114
|
+
bucket.presentationDelays.push(phases.presentationDelay);
|
|
64
115
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
observer.observe({ type: "event", durationThreshold: 0, buffered: true });
|
|
116
|
+
return byEventType;
|
|
117
|
+
};
|
|
68
118
|
|
|
69
119
|
const p75 = (arr) => {
|
|
70
120
|
const sorted = [...arr].sort((a, b) => a - b);
|
|
@@ -74,6 +124,7 @@
|
|
|
74
124
|
};
|
|
75
125
|
|
|
76
126
|
window.getInputLatencyBreakdown = () => {
|
|
127
|
+
const byEventType = groupByEventType();
|
|
77
128
|
const types = Object.keys(byEventType);
|
|
78
129
|
|
|
79
130
|
if (types.length === 0) {
|
|
@@ -101,7 +152,7 @@
|
|
|
101
152
|
const p75InputDelay = p75(b.inputDelays);
|
|
102
153
|
const p75Processing = p75(b.processingTimes);
|
|
103
154
|
const p75Presentation = p75(b.presentationDelays);
|
|
104
|
-
const p75Sum = p75InputDelay + p75Processing + p75Presentation;
|
|
155
|
+
const p75Sum = p75InputDelay + p75Processing + p75Presentation || 1;
|
|
105
156
|
|
|
106
157
|
const phases = [
|
|
107
158
|
{ name: "Input Delay", value: p75InputDelay },
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
|
|
6
|
+
console.warn("⚠️ event entries are not supported in this browser.");
|
|
7
|
+
return { script: "Interactions", status: "unsupported", error: "event entries not supported in this browser" };
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
11
|
|
|
7
12
|
// INP thresholds
|
|
@@ -20,45 +25,86 @@
|
|
|
20
25
|
poor: "🔴",
|
|
21
26
|
};
|
|
22
27
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
// @shared createInteractionTracker
|
|
29
|
+
// Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
|
|
30
|
+
// interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
|
|
31
|
+
// interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
|
|
32
|
+
// durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
|
|
33
|
+
// down, has its own timing. It is reported under the event whose handler ran longest, or under the
|
|
34
|
+
// click or keydown when no handler took a millisecond more than theirs.
|
|
35
|
+
function createInteractionTracker() {
|
|
36
|
+
const byId = new Map();
|
|
37
|
+
const processing = (e) => e.processingEnd - e.processingStart;
|
|
38
|
+
const measure = (id, entries) => {
|
|
39
|
+
const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
|
|
40
|
+
const paint = longest.startTime + longest.duration;
|
|
41
|
+
const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
|
|
42
|
+
const startTime = Math.min(...frame.map((e) => e.startTime));
|
|
43
|
+
const processingStart = Math.min(...frame.map((e) => e.processingStart));
|
|
44
|
+
const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
|
|
45
|
+
const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
|
|
46
|
+
const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
|
|
47
|
+
const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
|
|
48
|
+
return {
|
|
49
|
+
interactionId: id,
|
|
50
|
+
name: reported.name,
|
|
51
|
+
target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
|
|
52
|
+
startTime,
|
|
53
|
+
duration: longest.duration,
|
|
54
|
+
phases: {
|
|
55
|
+
inputDelay: processingStart - startTime,
|
|
56
|
+
processingTime: processingEnd - processingStart,
|
|
57
|
+
presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
// Adds the entries of one observer callback and returns the interactions they changed
|
|
63
|
+
add(entries) {
|
|
64
|
+
const changed = new Set();
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
if (!e.interactionId) continue;
|
|
67
|
+
const record = byId.get(e.interactionId) ?? { entries: [] };
|
|
68
|
+
record.entries.push({
|
|
69
|
+
name: e.name,
|
|
70
|
+
target: e.target,
|
|
71
|
+
startTime: e.startTime,
|
|
72
|
+
duration: e.duration,
|
|
73
|
+
processingStart: e.processingStart,
|
|
74
|
+
processingEnd: e.processingEnd,
|
|
75
|
+
});
|
|
76
|
+
byId.set(e.interactionId, record);
|
|
77
|
+
changed.add(e.interactionId);
|
|
78
|
+
}
|
|
79
|
+
return [...changed].map((id) => {
|
|
80
|
+
const record = byId.get(id);
|
|
81
|
+
record.summary = measure(id, record.entries);
|
|
82
|
+
return record.summary;
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
list() {
|
|
86
|
+
return [...byId.values()].map((record) => record.summary);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// @end-shared createInteractionTracker
|
|
35
91
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
92
|
+
// One record per interaction for the summary, even when its entries arrive in separate callbacks
|
|
93
|
+
const tracker = createInteractionTracker();
|
|
94
|
+
const logged = new Set();
|
|
40
95
|
|
|
41
|
-
|
|
96
|
+
const observer = new PerformanceObserver((list) => {
|
|
97
|
+
for (const interaction of tracker.add(list.getEntries())) {
|
|
98
|
+
const value = interaction.duration;
|
|
42
99
|
const rating = valueToRating(value);
|
|
43
100
|
const icon = RATING_ICONS[rating];
|
|
44
101
|
const color = RATING_COLORS[rating];
|
|
102
|
+
const updated = logged.has(interaction.interactionId);
|
|
103
|
+
logged.add(interaction.interactionId);
|
|
45
104
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
rating,
|
|
50
|
-
target: entry.target,
|
|
51
|
-
type: entry.name,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Calculate sub-parts
|
|
55
|
-
const inputDelay = entry.processingStart - entry.startTime;
|
|
56
|
-
const processingTime = entry.processingEnd - entry.processingStart;
|
|
57
|
-
const presentationDelay = Math.max(
|
|
58
|
-
4,
|
|
59
|
-
entry.startTime + entry.duration - entry.processingEnd
|
|
60
|
-
);
|
|
61
|
-
const total = inputDelay + processingTime + presentationDelay;
|
|
105
|
+
// Sub-parts of the whole interaction
|
|
106
|
+
const { inputDelay, processingTime, presentationDelay } = interaction.phases;
|
|
107
|
+
const total = inputDelay + processingTime + presentationDelay || 1;
|
|
62
108
|
|
|
63
109
|
// Find longest sub-part
|
|
64
110
|
const subParts = [
|
|
@@ -69,13 +115,13 @@
|
|
|
69
115
|
const longest = subParts.reduce((a, b) => (a.value > b.value ? a : b));
|
|
70
116
|
|
|
71
117
|
console.groupCollapsed(
|
|
72
|
-
`%c${icon} Interaction: ${formatMs(value)} (${rating})`,
|
|
118
|
+
`%c${icon} Interaction${updated ? " (updated)" : ""}: ${formatMs(value)} (${rating})`,
|
|
73
119
|
`font-weight: bold; color: ${color};`
|
|
74
120
|
);
|
|
75
121
|
|
|
76
122
|
// Target info
|
|
77
|
-
console.log("%cTarget:", "font-weight: bold;",
|
|
78
|
-
console.log(` Event type: ${
|
|
123
|
+
console.log("%cTarget:", "font-weight: bold;", interaction.target);
|
|
124
|
+
console.log(` Event type: ${interaction.name}`);
|
|
79
125
|
|
|
80
126
|
// Sub-parts breakdown
|
|
81
127
|
console.log("");
|
|
@@ -129,6 +175,12 @@
|
|
|
129
175
|
|
|
130
176
|
// Summary function
|
|
131
177
|
window.getInteractionSummary = () => {
|
|
178
|
+
const allInteractions = tracker.list().map((i) => ({
|
|
179
|
+
duration: i.duration,
|
|
180
|
+
rating: valueToRating(i.duration),
|
|
181
|
+
target: i.target,
|
|
182
|
+
type: i.name,
|
|
183
|
+
}));
|
|
132
184
|
if (allInteractions.length === 0) {
|
|
133
185
|
console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
|
|
134
186
|
console.log(" Interact with the page (click, type, etc.) and call this again.");
|
|
@@ -149,11 +201,11 @@
|
|
|
149
201
|
console.log("%cStatistics:", "font-weight: bold;");
|
|
150
202
|
console.log(` Total interactions: ${allInteractions.length}`);
|
|
151
203
|
console.log(
|
|
152
|
-
` Worst: %c${formatMs(worst)} (${worstRating})`,
|
|
204
|
+
` Worst (approximates INP): %c${formatMs(worst)} (${worstRating})`,
|
|
153
205
|
`color: ${RATING_COLORS[worstRating]};`
|
|
154
206
|
);
|
|
155
207
|
console.log(
|
|
156
|
-
` P75
|
|
208
|
+
` P75: %c${formatMs(p75)} (${p75Rating})`,
|
|
157
209
|
`color: ${RATING_COLORS[p75Rating]};`
|
|
158
210
|
);
|
|
159
211
|
console.log(` Average: ${formatMs(avg)}`);
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
// Firefox and Safari have no Long Animation Frames: an empty list there is not a pass.
|
|
6
|
+
// The summary function answers the same, for an agent that calls it later.
|
|
7
|
+
if (!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame")) {
|
|
8
|
+
const unsupported = {
|
|
9
|
+
script: "Long-Animation-Frames",
|
|
10
|
+
status: "unsupported",
|
|
11
|
+
error: "long-animation-frame entries not supported in this browser (Chrome 123+)",
|
|
12
|
+
};
|
|
13
|
+
window.getLoAFSummary = () => unsupported;
|
|
14
|
+
console.log(
|
|
15
|
+
"%c⚠️ Long Animation Frames are not supported in this browser (Chrome 123+, Edge 123+)",
|
|
16
|
+
"color: #f59e0b; font-weight: bold;"
|
|
17
|
+
);
|
|
18
|
+
return unsupported;
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
6
22
|
|
|
7
23
|
// Rating based on blocking duration
|
|
@@ -185,7 +201,10 @@
|
|
|
185
201
|
});
|
|
186
202
|
|
|
187
203
|
loafObserver.observe({ type: "long-animation-frame", buffered: true });
|
|
188
|
-
|
|
204
|
+
// Events only add context to the frames, so the frames are tracked without them
|
|
205
|
+
if (PerformanceObserver.supportedEntryTypes.includes("event")) {
|
|
206
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
207
|
+
}
|
|
189
208
|
|
|
190
209
|
// Summary function
|
|
191
210
|
window.getLoAFSummary = () => {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
// @end-shared isOwnRequest
|
|
30
30
|
|
|
31
31
|
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
32
|
-
// They count as first party. Example
|
|
32
|
+
// They count as first party. Example, for a page on example.com whose CDN is example.net:
|
|
33
|
+
// const OWN_DOMAINS = ["example.net"];
|
|
33
34
|
const OWN_DOMAINS = [];
|
|
34
35
|
|
|
35
36
|
// --- Helpers ---
|
package/snippets/Loading/FCP.js
CHANGED
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
|
|
23
23
|
const ms = (val) => `${Math.round(val)}ms`;
|
|
24
24
|
|
|
25
|
+
// @shared supportsRenderBlockingStatus
|
|
26
|
+
// renderBlockingStatus (Resource Timing) is Chromium-only (Chrome 107+). Without it, which
|
|
27
|
+
// resources block rendering is unknown: a snippet reports that, not "none".
|
|
28
|
+
function supportsRenderBlockingStatus() {
|
|
29
|
+
return !!window.PerformanceResourceTiming && "renderBlockingStatus" in window.PerformanceResourceTiming.prototype;
|
|
30
|
+
}
|
|
31
|
+
// @end-shared supportsRenderBlockingStatus
|
|
32
|
+
|
|
33
|
+
// Without it, the time after TTFB is not split into render-blocking load and render delay
|
|
34
|
+
const renderBlockingSupported = supportsRenderBlockingStatus();
|
|
35
|
+
|
|
25
36
|
new PerformanceObserver((list) => {
|
|
26
37
|
const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
|
|
27
38
|
if (!fcpEntry) return;
|
|
@@ -54,6 +65,16 @@
|
|
|
54
65
|
console.log("");
|
|
55
66
|
console.log("%cPhase Breakdown:", "font-weight: bold;");
|
|
56
67
|
console.log(` TTFB: ${ms(ttfb)}`);
|
|
68
|
+
if (!renderBlockingSupported) {
|
|
69
|
+
console.log(` Render-blocking load and render delay: ${ms(Math.max(0, fcpTime - ttfb))}`);
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log(
|
|
72
|
+
"%cℹ️ renderBlockingStatus is not supported in this browser (Chrome 107+), so render-blocking resources cannot be identified",
|
|
73
|
+
"color: #3b82f6;"
|
|
74
|
+
);
|
|
75
|
+
console.groupEnd();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
57
78
|
if (blockingResources.length > 0) {
|
|
58
79
|
console.log(
|
|
59
80
|
` Render-blocking load: ${ms(Math.max(0, lastBlockingEnd - ttfb))}`
|
|
@@ -103,18 +124,23 @@
|
|
|
103
124
|
const ratingSync = valueToRating(fcpTimeSync);
|
|
104
125
|
|
|
105
126
|
// Phases, measured from activation like the value: TTFB, then the wait for the last
|
|
106
|
-
// render-blocking resource, then the rest until the paint
|
|
127
|
+
// render-blocking resource, then the rest until the paint. Without renderBlockingStatus the
|
|
128
|
+
// two phases after TTFB are null, not 0 and the whole rest.
|
|
107
129
|
const ttfbSync = Math.max(0, (navEntrySync?.responseStart ?? 0) - activationStartSync);
|
|
108
|
-
const blockingSync =
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
const blockingSync = renderBlockingSupported
|
|
131
|
+
? performance
|
|
132
|
+
.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
|
|
133
|
+
.filter((r) => r.renderBlockingStatus === "blocking")
|
|
134
|
+
: [];
|
|
111
135
|
const lastBlockingEndSync = Math.max(
|
|
112
136
|
ttfbSync,
|
|
113
137
|
...blockingSync.map((r) => r.responseEnd - activationStartSync)
|
|
114
138
|
);
|
|
115
139
|
const ttfbMs = Math.round(ttfbSync);
|
|
116
|
-
const renderBlockingLoadMs = Math.round(lastBlockingEndSync - ttfbSync);
|
|
117
|
-
const renderDelayMs =
|
|
140
|
+
const renderBlockingLoadMs = renderBlockingSupported ? Math.round(lastBlockingEndSync - ttfbSync) : null;
|
|
141
|
+
const renderDelayMs = renderBlockingSupported
|
|
142
|
+
? Math.max(0, Math.round(fcpTimeSync) - ttfbMs - renderBlockingLoadMs)
|
|
143
|
+
: null;
|
|
118
144
|
|
|
119
145
|
return {
|
|
120
146
|
script: "FCP",
|
|
@@ -128,7 +154,8 @@
|
|
|
128
154
|
ttfbMs,
|
|
129
155
|
renderBlockingLoadMs,
|
|
130
156
|
renderDelayMs,
|
|
131
|
-
blockingResourceCount: blockingSync.length,
|
|
157
|
+
blockingResourceCount: renderBlockingSupported ? blockingSync.length : null,
|
|
158
|
+
renderBlockingSupported,
|
|
132
159
|
},
|
|
133
160
|
// Render-blocking resources, the last to finish first (at most 50)
|
|
134
161
|
items: [...blockingSync]
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
}
|
|
11
11
|
// @end-shared isOwnRequest
|
|
12
12
|
|
|
13
|
+
// @shared supportsRenderBlockingStatus
|
|
14
|
+
// renderBlockingStatus (Resource Timing) is Chromium-only (Chrome 107+). Without it, which
|
|
15
|
+
// resources block rendering is unknown: a snippet reports that, not "none".
|
|
16
|
+
function supportsRenderBlockingStatus() {
|
|
17
|
+
return !!window.PerformanceResourceTiming && "renderBlockingStatus" in window.PerformanceResourceTiming.prototype;
|
|
18
|
+
}
|
|
19
|
+
// @end-shared supportsRenderBlockingStatus
|
|
20
|
+
|
|
13
21
|
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
14
|
-
// They count as first party. Example
|
|
22
|
+
// They count as first party. Example, for a page on example.com whose CDN is example.net:
|
|
23
|
+
// const OWN_DOMAINS = ["example.net"];
|
|
15
24
|
const OWN_DOMAINS = [];
|
|
16
25
|
|
|
17
26
|
// Auto-detect first-party: same root domain (handles subdomains)
|
|
@@ -60,10 +69,32 @@
|
|
|
60
69
|
}
|
|
61
70
|
// @end-shared formatBytes
|
|
62
71
|
|
|
63
|
-
//
|
|
64
|
-
const
|
|
72
|
+
// Without it, renderBlocking is null for every script instead of false
|
|
73
|
+
const renderBlockingSupported = supportsRenderBlockingStatus();
|
|
74
|
+
|
|
75
|
+
// A script fetched through <link rel="preload" as="script"> or <link rel="modulepreload"> has
|
|
76
|
+
// initiatorType "link" or "other", not "script", so its URL is matched against the document
|
|
77
|
+
const documentScriptUrls = new Set(
|
|
78
|
+
Array.from(
|
|
79
|
+
document.querySelectorAll('script[src], link[rel~="preload" i][as="script" i], link[rel~="modulepreload" i]'),
|
|
80
|
+
(el) => (el.localName === "link" ? el.href : el.src)
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
const isScript = (r) => r.initiatorType === "script" || documentScriptUrls.has(r.name);
|
|
84
|
+
|
|
85
|
+
// One entry per URL: a script preloaded in one mode and loaded in another is fetched twice, and
|
|
86
|
+
// the entry of the <script> is the one that tells whether it blocks rendering
|
|
87
|
+
const scriptEntries = new Map();
|
|
88
|
+
performance
|
|
65
89
|
.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
|
|
66
|
-
.filter(
|
|
90
|
+
.filter(isScript)
|
|
91
|
+
.forEach((r) => {
|
|
92
|
+
const seen = scriptEntries.get(r.name);
|
|
93
|
+
if (!seen || (seen.initiatorType !== "script" && r.initiatorType === "script")) scriptEntries.set(r.name, r);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Gather script data
|
|
97
|
+
const scripts = [...scriptEntries.values()]
|
|
67
98
|
.map((r) => {
|
|
68
99
|
const url = new URL(r.name);
|
|
69
100
|
const firstParty = isFirstParty(url.hostname);
|
|
@@ -79,7 +110,7 @@
|
|
|
79
110
|
sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
|
|
80
111
|
startTime: r.startTime,
|
|
81
112
|
responseEnd: r.responseEnd,
|
|
82
|
-
renderBlocking: r.renderBlockingStatus === "blocking",
|
|
113
|
+
renderBlocking: renderBlockingSupported ? r.renderBlockingStatus === "blocking" : null,
|
|
83
114
|
};
|
|
84
115
|
});
|
|
85
116
|
|
|
@@ -91,7 +122,7 @@
|
|
|
91
122
|
count: list.length,
|
|
92
123
|
totalSize: list.reduce((sum, s) => sum + s.transferSize, 0),
|
|
93
124
|
totalDuration: list.reduce((sum, s) => sum + s.duration, 0),
|
|
94
|
-
blocking: list.filter((s) => s.renderBlocking).length,
|
|
125
|
+
blocking: renderBlockingSupported ? list.filter((s) => s.renderBlocking).length : null,
|
|
95
126
|
hosts: [...new Set(list.map((s) => s.host))],
|
|
96
127
|
});
|
|
97
128
|
|
|
@@ -129,7 +160,7 @@
|
|
|
129
160
|
console.log("No first-party scripts found.");
|
|
130
161
|
} else {
|
|
131
162
|
console.log(` Total size: ${formatBytes(firstMetrics.totalSize)}`);
|
|
132
|
-
console.log(` Render-blocking: ${firstMetrics.blocking}`);
|
|
163
|
+
console.log(` Render-blocking: ${firstMetrics.blocking ?? "n/a"}`);
|
|
133
164
|
console.log("");
|
|
134
165
|
|
|
135
166
|
const firstTable = firstParty
|
|
@@ -138,7 +169,7 @@
|
|
|
138
169
|
Script: s.shortName,
|
|
139
170
|
Size: formatBytes(s.transferSize),
|
|
140
171
|
Duration: s.duration.toFixed(0) + "ms",
|
|
141
|
-
Blocking: s.renderBlocking ? "⚠️ Yes" : "No",
|
|
172
|
+
Blocking: s.renderBlocking === null ? "n/a" : s.renderBlocking ? "⚠️ Yes" : "No",
|
|
142
173
|
Host: s.host,
|
|
143
174
|
}));
|
|
144
175
|
console.table(firstTable);
|
|
@@ -156,7 +187,7 @@
|
|
|
156
187
|
console.log("%c✅ No third-party scripts found!", "color: #22c55e; font-weight: bold;");
|
|
157
188
|
} else {
|
|
158
189
|
console.log(` Total size: ${formatBytes(thirdMetrics.totalSize)}`);
|
|
159
|
-
console.log(` Render-blocking: ${thirdMetrics.blocking}`);
|
|
190
|
+
console.log(` Render-blocking: ${thirdMetrics.blocking ?? "n/a"}`);
|
|
160
191
|
console.log(` Unique hosts: ${thirdMetrics.hosts.length}`);
|
|
161
192
|
console.log("");
|
|
162
193
|
|
|
@@ -187,7 +218,7 @@
|
|
|
187
218
|
Host: s.host,
|
|
188
219
|
Size: formatBytes(s.transferSize),
|
|
189
220
|
Duration: s.duration.toFixed(0) + "ms",
|
|
190
|
-
Blocking: s.renderBlocking ? "⚠️ Yes" : "No",
|
|
221
|
+
Blocking: s.renderBlocking === null ? "n/a" : s.renderBlocking ? "⚠️ Yes" : "No",
|
|
191
222
|
}));
|
|
192
223
|
console.table(thirdTable);
|
|
193
224
|
}
|
|
@@ -221,7 +252,7 @@
|
|
|
221
252
|
console.log(" • Each host requires DNS lookup + connection");
|
|
222
253
|
console.log(" • Use <link rel='preconnect'> for critical hosts");
|
|
223
254
|
console.log(
|
|
224
|
-
'%c <link rel="preconnect" href="https://
|
|
255
|
+
'%c <link rel="preconnect" href="https://example.com">',
|
|
225
256
|
"font-family: monospace; color: #22c55e;",
|
|
226
257
|
);
|
|
227
258
|
}
|
|
@@ -247,6 +278,11 @@
|
|
|
247
278
|
console.groupEnd();
|
|
248
279
|
}
|
|
249
280
|
|
|
281
|
+
if (!renderBlockingSupported && totalScripts > 0) {
|
|
282
|
+
console.log("");
|
|
283
|
+
console.log("%cℹ️ renderBlockingStatus is not supported; render-blocking status is unavailable (Chrome 107+)", "color: #3b82f6;");
|
|
284
|
+
}
|
|
285
|
+
|
|
250
286
|
logOwnDomainsHint(thirdParty.length);
|
|
251
287
|
console.groupEnd();
|
|
252
288
|
|
|
@@ -272,6 +308,7 @@
|
|
|
272
308
|
thirdPartyBlockingCount: thirdMetrics.blocking,
|
|
273
309
|
thirdPartyHostCount: thirdMetrics.hosts.length,
|
|
274
310
|
sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
|
|
311
|
+
renderBlockingSupported,
|
|
275
312
|
},
|
|
276
313
|
items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
|
|
277
314
|
shortName: s.shortName,
|
|
@@ -286,6 +323,9 @@
|
|
|
286
323
|
...(scripts.some((s) => !s.sizeKnown)
|
|
287
324
|
? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
|
|
288
325
|
: []),
|
|
326
|
+
...(!renderBlockingSupported && totalScripts > 0
|
|
327
|
+
? [{ severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
|
|
328
|
+
: []),
|
|
289
329
|
...(thirdMetrics.blocking > 0
|
|
290
330
|
? [
|
|
291
331
|
{
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// @end-shared isOwnRequest
|
|
12
12
|
|
|
13
13
|
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
14
|
-
// They count as first party. Example
|
|
14
|
+
// They count as first party. Example, for a page on example.com whose CDN is example.net:
|
|
15
|
+
// const OWN_DOMAINS = ["example.net"];
|
|
15
16
|
const OWN_DOMAINS = [];
|
|
16
17
|
|
|
17
18
|
// Auto-detect first-party by root domain
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
5
|
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
6
|
-
// They count as first party. Example
|
|
6
|
+
// They count as first party. Example, for a page on example.com whose CDN is example.net:
|
|
7
|
+
// const OWN_DOMAINS = ["example.net"];
|
|
7
8
|
const OWN_DOMAINS = [];
|
|
8
9
|
|
|
9
10
|
// Helper to extract font filename from URL
|