webperf-snippets 1.0.0 → 1.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.
@@ -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
- // Interactions grouped by event type
23
- const byEventType = {};
24
-
25
- const observer = new PerformanceObserver((list) => {
26
- // Group entries by interactionId; keep the longest entry per interaction
27
- const interactions = {};
28
-
29
- for (const entry of list.getEntries().filter((e) => e.interactionId)) {
30
- interactions[entry.interactionId] =
31
- interactions[entry.interactionId] || [];
32
- interactions[entry.interactionId].push(entry);
33
- }
34
-
35
- for (const group of Object.values(interactions)) {
36
- const entry = group.reduce((prev, curr) =>
37
- prev.duration >= curr.duration ? prev : curr
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
- const eventType = entry.name; // "click", "keydown", "pointerdown", etc.
41
- const inputDelay = entry.processingStart - entry.startTime;
42
- const processingTime = entry.processingEnd - entry.processingStart;
43
- const presentationDelay = Math.max(
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
- if (!byEventType[eventType]) {
49
- byEventType[eventType] = {
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(entry.duration);
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
- // Track all interactions for summary
24
- const allInteractions = [];
25
-
26
- const observer = new PerformanceObserver((list) => {
27
- const interactions = {};
28
-
29
- for (const entry of list
30
- .getEntries()
31
- .filter((entry) => entry.interactionId)) {
32
- interactions[entry.interactionId] = interactions[entry.interactionId] || [];
33
- interactions[entry.interactionId].push(entry);
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
- for (const interaction of Object.values(interactions)) {
37
- const entry = interaction.reduce((prev, curr) =>
38
- prev.duration >= curr.duration ? prev : curr
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
- const value = entry.duration;
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
- // Store for summary
47
- allInteractions.push({
48
- duration: value,
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;", entry.target);
78
- console.log(` Event type: ${entry.name}`);
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 (INP): %c${formatMs(p75)} (${p75Rating})`,
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
- eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
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: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
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 ---
@@ -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 = performance
109
- .getEntriesByType("resource").filter((e) => !isOwnRequest(e))
110
- .filter((r) => r.renderBlockingStatus === "blocking");
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 = Math.max(0, Math.round(fcpTimeSync) - ttfbMs - renderBlockingLoadMs);
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: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
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
- // Gather script data
64
- const scripts = performance
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((r) => r.initiatorType === "script")
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://web.dev">',
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: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
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: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
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