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
|
@@ -14,6 +14,21 @@
|
|
|
14
14
|
|
|
15
15
|
const formatKB = (bytes) => (bytes / 1024).toFixed(2);
|
|
16
16
|
|
|
17
|
+
// Framework data: JSON scripts, the ids frameworks give their hydration data, and scripts that
|
|
18
|
+
// only push data into a framework global or assign its state. They can carry any text, such as
|
|
19
|
+
// the source of another script, so they are classified before any pattern is looked for.
|
|
20
|
+
const DATA_IDS = ["__NEXT_DATA__", "__NUXT_DATA__", "__remixContext"];
|
|
21
|
+
// (self.__next_f = self.__next_f || []).push(...) or self.__next_f.push(...)
|
|
22
|
+
const DATA_PUSH = /^\s*\(?\s*(?:self|window|globalThis)\.(__\w+)\s*(?:=\s*(?:self|window|globalThis)\.\1\s*\|\|\s*\[\s*\]\s*\)\s*)?\.push\s*\(/;
|
|
23
|
+
// window.__NUXT__ = ..., window.__remixContext = ..., window.___chunkMapping = ..., window.__INITIAL_STATE__ = ...
|
|
24
|
+
const DATA_ASSIGN = /^\s*(?:self|window|globalThis)\.(?:__NUXT__|__remixContext|___\w+|__\w*(?:STATE|DATA)\w*)\s*=/;
|
|
25
|
+
const isDataPayloadScript = (script, type, content) =>
|
|
26
|
+
type.includes("json") || DATA_IDS.includes(script.id) || DATA_PUSH.test(content) || DATA_ASSIGN.test(content);
|
|
27
|
+
|
|
28
|
+
// Analytics bootstraps define or call the tag function, or push into its queue; a string that
|
|
29
|
+
// only contains the word, such as a key or a URL, does not count
|
|
30
|
+
const ANALYTICS = /\b(?:gtag|fbq)\s*\(|\b(?:_gaq|dataLayer)\.push\s*\(|['"]gtm\.(?:start|js)['"]|\banalytics\.(?:load|page|track|identify)\s*\(/;
|
|
31
|
+
|
|
17
32
|
// Get all inline scripts (no src attribute)
|
|
18
33
|
const scriptTags = Array.from(
|
|
19
34
|
document.querySelectorAll("script:not([src])")
|
|
@@ -58,10 +73,11 @@
|
|
|
58
73
|
.trim()
|
|
59
74
|
.slice(0, 80);
|
|
60
75
|
|
|
61
|
-
// Check for common patterns
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
76
|
+
// Check for common patterns; data payloads first, so nothing in their data counts as code
|
|
77
|
+
const isDataPayload = isDataPayloadScript(script, type, content);
|
|
78
|
+
const hasGlobalVars = !isDataPayload && /^(var|let|const|window\.)/.test(content.trim());
|
|
79
|
+
const isConfig = !isDataPayload && /config|settings|options|__/i.test(content.slice(0, 200));
|
|
80
|
+
const isAnalytics = !isDataPayload && ANALYTICS.test(content);
|
|
65
81
|
|
|
66
82
|
return {
|
|
67
83
|
index: index + 1,
|
|
@@ -75,6 +91,7 @@
|
|
|
75
91
|
hasGlobalVars,
|
|
76
92
|
isConfig,
|
|
77
93
|
isAnalytics,
|
|
94
|
+
isDataPayload,
|
|
78
95
|
preview: preview + (content.length > 80 ? "..." : ""),
|
|
79
96
|
content,
|
|
80
97
|
};
|
|
@@ -164,7 +181,9 @@
|
|
|
164
181
|
Type: s.category,
|
|
165
182
|
Size: formatBytes(s.size),
|
|
166
183
|
"Parser-Blocking": s.isParserBlocking ? "⚠️ Yes" : "No",
|
|
167
|
-
Pattern: s.
|
|
184
|
+
Pattern: s.isDataPayload
|
|
185
|
+
? "Data"
|
|
186
|
+
: s.isAnalytics
|
|
168
187
|
? "Analytics"
|
|
169
188
|
: s.isConfig
|
|
170
189
|
? "Config"
|
|
@@ -323,8 +342,9 @@
|
|
|
323
342
|
totalSizeBytes: totalSize,
|
|
324
343
|
parserBlockingCount: parserBlockingScripts.length,
|
|
325
344
|
parserBlockingInHeadCount: parserBlockingInHead.length,
|
|
345
|
+
dataPayloadCount: scripts.filter((s) => s.isDataPayload).length,
|
|
326
346
|
},
|
|
327
|
-
items: rankedScripts.slice(0, MAX_ITEMS).map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig })),
|
|
347
|
+
items: rankedScripts.slice(0, MAX_ITEMS).map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig, isDataPayload: s.isDataPayload })),
|
|
328
348
|
issues: issues.map(i => ({ severity: i.type === "blocking" ? "error" : "warning", message: i.message })),
|
|
329
349
|
};
|
|
330
350
|
})();
|
|
@@ -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
|
// @shared formatBytes
|
|
@@ -98,10 +99,15 @@
|
|
|
98
99
|
const isModule = script.type === "module";
|
|
99
100
|
const isAsync = script.async;
|
|
100
101
|
const isDefer = script.defer;
|
|
102
|
+
// A nomodule script is the fallback for browsers without ES modules. A browser that has the
|
|
103
|
+
// noModule property supports modules, so it never fetches or runs the script: it blocks nothing.
|
|
104
|
+
const isNoModule = script.noModule === true && !isModule;
|
|
101
105
|
|
|
102
106
|
// Determine loading strategy
|
|
103
107
|
let strategy = "blocking";
|
|
104
|
-
if (
|
|
108
|
+
if (isNoModule) {
|
|
109
|
+
strategy = "nomodule";
|
|
110
|
+
} else if (isModule && isAsync) {
|
|
105
111
|
strategy = "async module";
|
|
106
112
|
} else if (isModule) {
|
|
107
113
|
strategy = "module";
|
|
@@ -125,7 +131,8 @@
|
|
|
125
131
|
isAsync,
|
|
126
132
|
isDefer,
|
|
127
133
|
size: timing.size || 0,
|
|
128
|
-
|
|
134
|
+
// A nomodule script is never fetched, so its size is known: nothing was downloaded
|
|
135
|
+
sizeKnown: !!timing.sizeKnown || (isNoModule && !resourceTiming.has(src)),
|
|
129
136
|
duration: timing.duration || 0,
|
|
130
137
|
startTime: timing.startTime || 0,
|
|
131
138
|
element: script,
|
|
@@ -138,6 +145,7 @@
|
|
|
138
145
|
const asyncScripts = scripts.filter((s) => s.strategy === "async" || s.strategy === "async module");
|
|
139
146
|
const deferScripts = scripts.filter((s) => s.strategy === "defer");
|
|
140
147
|
const moduleScripts = scripts.filter((s) => s.strategy === "module");
|
|
148
|
+
const noModuleScripts = scripts.filter((s) => s.strategy === "nomodule");
|
|
141
149
|
const thirdPartyBlocking = blocking.filter((s) => !s.firstParty);
|
|
142
150
|
|
|
143
151
|
// Calculate totals
|
|
@@ -172,6 +180,9 @@
|
|
|
172
180
|
console.log(` ⚡ Async: ${asyncScripts.length}`);
|
|
173
181
|
console.log(` 📋 Defer: ${deferScripts.length}`);
|
|
174
182
|
console.log(` 📦 Module: ${moduleScripts.length}`);
|
|
183
|
+
if (noModuleScripts.length > 0) {
|
|
184
|
+
console.log(` ⏭️ nomodule (not fetched by this browser): ${noModuleScripts.length}`);
|
|
185
|
+
}
|
|
175
186
|
console.log("");
|
|
176
187
|
console.log(` Rating: %c${rating}`, `color: ${ratingColor}; font-weight: bold;`);
|
|
177
188
|
|
|
@@ -361,6 +372,7 @@
|
|
|
361
372
|
async: asyncScripts.length,
|
|
362
373
|
defer: deferScripts.length,
|
|
363
374
|
module: moduleScripts.length,
|
|
375
|
+
nomodule: noModuleScripts.length,
|
|
364
376
|
},
|
|
365
377
|
byParty: {
|
|
366
378
|
firstParty: firstPartyScripts.length,
|
|
@@ -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
|
// Waiting time for the first byte of a resource: responseStart is measured from the
|
|
@@ -11,12 +11,21 @@
|
|
|
11
11
|
}
|
|
12
12
|
// @end-shared isOwnRequest
|
|
13
13
|
|
|
14
|
+
// @shared supportsRenderBlockingStatus
|
|
15
|
+
// renderBlockingStatus (Resource Timing) is Chromium-only (Chrome 107+). Without it, which
|
|
16
|
+
// resources block rendering is unknown: a snippet reports that, not "none".
|
|
17
|
+
function supportsRenderBlockingStatus() {
|
|
18
|
+
return !!window.PerformanceResourceTiming && "renderBlockingStatus" in window.PerformanceResourceTiming.prototype;
|
|
19
|
+
}
|
|
20
|
+
// @end-shared supportsRenderBlockingStatus
|
|
21
|
+
|
|
14
22
|
const MAX_ITEMS = 50;
|
|
15
23
|
const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
|
|
16
24
|
const MAX_LISTED_DOMAINS = 5;
|
|
17
25
|
|
|
18
26
|
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
19
|
-
// They count as first party. Example
|
|
27
|
+
// They count as first party. Example, for a page on example.com whose CDN is example.net:
|
|
28
|
+
// const OWN_DOMAINS = ["example.net"];
|
|
20
29
|
const OWN_DOMAINS = [];
|
|
21
30
|
|
|
22
31
|
// @shared formatBytes
|
|
@@ -80,10 +89,13 @@
|
|
|
80
89
|
}
|
|
81
90
|
};
|
|
82
91
|
|
|
92
|
+
// Without it, renderBlocking is null for every domain instead of false
|
|
93
|
+
const renderBlockingSupported = supportsRenderBlockingStatus();
|
|
94
|
+
|
|
83
95
|
const domains = new Map();
|
|
84
96
|
const domainEntry = (root) => {
|
|
85
97
|
if (!domains.has(root)) {
|
|
86
|
-
domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: false, loafMs: null, loafScripts: 0 });
|
|
98
|
+
domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: renderBlockingSupported ? false : null, loafMs: null, loafScripts: 0 });
|
|
87
99
|
}
|
|
88
100
|
return domains.get(root);
|
|
89
101
|
};
|
|
@@ -115,7 +127,7 @@
|
|
|
115
127
|
sizeUnknownCount++;
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
if (entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
|
|
130
|
+
if (renderBlockingSupported && entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
|
|
119
131
|
d.renderBlocking = true;
|
|
120
132
|
}
|
|
121
133
|
});
|
|
@@ -185,6 +197,12 @@
|
|
|
185
197
|
message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
|
|
186
198
|
});
|
|
187
199
|
});
|
|
200
|
+
if (!renderBlockingSupported && all.length > 0) {
|
|
201
|
+
issues.push({
|
|
202
|
+
severity: "info",
|
|
203
|
+
message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which third-party domains block rendering is unknown; run the snippet in Chrome or Edge to check",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
188
206
|
if (loafUnattributed) {
|
|
189
207
|
issues.push({
|
|
190
208
|
severity: "info",
|
|
@@ -208,13 +226,16 @@
|
|
|
208
226
|
Domain: d.domain,
|
|
209
227
|
Requests: d.requests,
|
|
210
228
|
Size: sizeLabel(d.transferBytes, d.sizeUnknownCount, d.requests),
|
|
211
|
-
"Render-blocking": d.renderBlocking ? "yes" : "no",
|
|
229
|
+
"Render-blocking": d.renderBlocking === null ? "n/a" : d.renderBlocking ? "yes" : "no",
|
|
212
230
|
"Main thread": d.loafMs === null ? "n/a" : d.loafMs + " ms",
|
|
213
231
|
}))
|
|
214
232
|
);
|
|
215
233
|
} else {
|
|
216
234
|
console.log("%c✅ No third-party requests found", "color: #22c55e;");
|
|
217
235
|
}
|
|
236
|
+
if (!renderBlockingSupported) {
|
|
237
|
+
console.log("%cℹ️ renderBlockingStatus is not supported; render-blocking status is unavailable (Chrome 107+)", "color: #3b82f6;");
|
|
238
|
+
}
|
|
218
239
|
if (!loafSupported) {
|
|
219
240
|
console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
|
|
220
241
|
}
|
|
@@ -242,7 +263,8 @@
|
|
|
242
263
|
longFrames,
|
|
243
264
|
unattributedLoafMs,
|
|
244
265
|
firstPartyRequests,
|
|
245
|
-
blockingDomains: blocking.length,
|
|
266
|
+
blockingDomains: renderBlockingSupported ? blocking.length : null,
|
|
267
|
+
renderBlockingSupported,
|
|
246
268
|
loafSupported,
|
|
247
269
|
sizeUnknownCount,
|
|
248
270
|
},
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -4,12 +4,11 @@ All scripts in the skills directory must return a structured JSON object as the
|
|
|
4
4
|
|
|
5
5
|
## Why this matters
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The console output of a snippet (with `%c` CSS styling, emojis, tables) is meant for humans reading DevTools. The return value is meant for agents. The scripts in the skills are built without console calls, so the return value of `evaluate_script` is the only output an agent gets.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
// Agent workflow
|
|
11
|
-
result = evaluate_script(scriptCode) // return value → structured JSON for agent
|
|
12
|
-
get_console_message() // console output → human debugging only
|
|
11
|
+
result = evaluate_script(scriptCode) // return value → structured JSON for the agent
|
|
13
12
|
```
|
|
14
13
|
|
|
15
14
|
---
|
|
@@ -136,7 +135,9 @@ Entry types that `getEntriesByType()` does return can be read synchronously: `na
|
|
|
136
135
|
})();
|
|
137
136
|
```
|
|
138
137
|
|
|
139
|
-
Always check `PerformanceObserver.supportedEntryTypes` first. On browsers without the entry type a script must return `status: "unsupported"`, not a value that looks like a passing result.
|
|
138
|
+
Always check `PerformanceObserver.supportedEntryTypes` first. On browsers without the entry type a script must return `status: "unsupported"`, not a value that looks like a passing result. A tracking script also defines its `getDataFn`, which returns the same `unsupported` object.
|
|
139
|
+
|
|
140
|
+
When only part of the result depends on an API, the script still runs: it sets a `details.<name>Supported` flag to `false` and reports the fields that depend on the API as `null`, never as `0` or `false`. `loafSupported` (Long Animation Frames) and `renderBlockingSupported` (`renderBlockingStatus` in Resource Timing, Chromium only) follow this rule.
|
|
140
141
|
|
|
141
142
|
The repository enforces this with an ESLint rule (`no-restricted-syntax` in `eslint.config.mjs`) that rejects `getEntriesByType()` for the observer-only types.
|
|
142
143
|
|
|
@@ -201,7 +202,7 @@ A script wrapped in `void (async () => {...})()` discards its return value. Neve
|
|
|
201
202
|
"details": {
|
|
202
203
|
"element": "img.hero",
|
|
203
204
|
"elementType": "Image",
|
|
204
|
-
"url": "https://
|
|
205
|
+
"url": "https://example.com/hero.jpg",
|
|
205
206
|
"sizePixels": 756000
|
|
206
207
|
}
|
|
207
208
|
}
|
|
@@ -261,17 +262,26 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
|
|
|
261
262
|
|
|
262
263
|
If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
|
|
263
264
|
|
|
264
|
-
`
|
|
265
|
+
The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
|
|
266
|
+
|
|
267
|
+
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "error"`, `count: 0` and no items.
|
|
265
268
|
|
|
266
269
|
```json
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
{
|
|
271
|
+
"script": "INP",
|
|
272
|
+
"status": "ok",
|
|
273
|
+
"count": 23,
|
|
274
|
+
"items": [
|
|
275
|
+
{
|
|
276
|
+
"formattedName": "click → button.submit",
|
|
277
|
+
"eventType": "click",
|
|
278
|
+
"target": "#checkout > button.submit",
|
|
279
|
+
"duration": 450,
|
|
280
|
+
"startTime": 1200,
|
|
281
|
+
"phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 }
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
}
|
|
275
285
|
```
|
|
276
286
|
|
|
277
287
|
#### LCP-Subparts
|
|
@@ -291,7 +301,7 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
|
|
|
291
301
|
"subParts": {
|
|
292
302
|
"ttfb": { "value": 450, "percent": 21, "overTarget": false },
|
|
293
303
|
"resourceLoadDelay": { "value": 120, "percent": 6, "overTarget": false },
|
|
294
|
-
"resourceLoadTime": { "value": 1200, "percent": 57, "overTarget": true },
|
|
304
|
+
"resourceLoadTime": { "value": 1200, "percent": 57, "overTarget": true, "waiting": 1040, "download": 160 },
|
|
295
305
|
"elementRenderDelay": { "value": 330, "percent": 16, "overTarget": true }
|
|
296
306
|
},
|
|
297
307
|
"slowestPhase": "resourceLoadTime"
|
|
@@ -299,6 +309,8 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
|
|
|
299
309
|
}
|
|
300
310
|
```
|
|
301
311
|
|
|
312
|
+
`resourceLoadTime.waiting` is the time until the first byte of the response of the LCP resource, and `resourceLoadTime.download` the time to download its body. Both are missing when Resource Timing hides that timing (another origin without `Timing-Allow-Origin`).
|
|
313
|
+
|
|
302
314
|
#### LCP-Trail
|
|
303
315
|
|
|
304
316
|
```json
|
|
@@ -329,7 +341,7 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
|
|
|
329
341
|
|
|
330
342
|
#### LCP-Image-Entropy
|
|
331
343
|
|
|
332
|
-
`items` holds the 50 most relevant images: the LCP image first, then the low-entropy ones, then the lowest bits per pixel. `count` and `details` cover every image.
|
|
344
|
+
`items` holds the 50 most relevant images: the LCP image first, then the low-entropy ones, then the lowest bits per pixel. `count` and `details` cover every image. `bpp` follows Chrome's rule: the encoded size in bits over the displayed area in CSS pixels (`displayedWidth` × `displayedHeight`), or over the natural area (`width` × `height`) when the image is upscaled. An image that is not rendered has no displayed area and is left out.
|
|
333
345
|
|
|
334
346
|
```json
|
|
335
347
|
{
|
|
@@ -351,6 +363,8 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
|
|
|
351
363
|
"url": "hero.jpg",
|
|
352
364
|
"width": 1200,
|
|
353
365
|
"height": 630,
|
|
366
|
+
"displayedWidth": 1200,
|
|
367
|
+
"displayedHeight": 630,
|
|
354
368
|
"fileSizeBytes": 156000,
|
|
355
369
|
"bpp": 1.65,
|
|
356
370
|
"isLowEntropy": false,
|
|
@@ -375,7 +389,7 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
|
|
|
375
389
|
"thresholds": { "good": 2500, "needsImprovement": 4000 },
|
|
376
390
|
"details": {
|
|
377
391
|
"isVideo": true,
|
|
378
|
-
"posterUrl": "https://
|
|
392
|
+
"posterUrl": "https://example.com/hero.avif",
|
|
379
393
|
"posterFormat": "avif",
|
|
380
394
|
"posterPreloaded": true,
|
|
381
395
|
"fetchpriorityOnPreload": "high",
|
|
@@ -451,7 +465,7 @@ When the rating is not good, `issues` holds one entry that names the TTFB and th
|
|
|
451
465
|
"items": [
|
|
452
466
|
{
|
|
453
467
|
"type": "link",
|
|
454
|
-
"url": "https://
|
|
468
|
+
"url": "https://example.com/style.css",
|
|
455
469
|
"shortName": "style.css",
|
|
456
470
|
"responseEndMs": 450,
|
|
457
471
|
"durationMs": 200,
|
|
@@ -463,7 +477,7 @@ When the rating is not good, `issues` holds one entry that names the TTFB and th
|
|
|
463
477
|
|
|
464
478
|
#### Script-Loading
|
|
465
479
|
|
|
466
|
-
`items` holds the 50 scripts to look at first: render-blocking, then third-party, then the largest. `count` and `details` cover every script. `corsLimitedAnalysis` is `true` when `details.sizeUnknownCount` is above zero, so `details.totalSizeBytes` is a lower bound.
|
|
480
|
+
`items` holds the 50 scripts to look at first: render-blocking, then third-party, then the largest. `count` and `details` cover every script. `corsLimitedAnalysis` is `true` when `details.sizeUnknownCount` is above zero, so `details.totalSizeBytes` is a lower bound. A classic script with `nomodule` has the strategy `nomodule` and is never blocking: a browser that supports modules does not fetch it, so its size is known (0 bytes).
|
|
467
481
|
|
|
468
482
|
```json
|
|
469
483
|
{
|
|
@@ -474,13 +488,13 @@ When the rating is not good, `issues` holds one entry that names the TTFB and th
|
|
|
474
488
|
"details": {
|
|
475
489
|
"totalSizeBytes": 245000,
|
|
476
490
|
"sizeUnknownCount": 0,
|
|
477
|
-
"byStrategy": { "blocking": 2, "async": 4, "defer": 1, "module": 1 },
|
|
491
|
+
"byStrategy": { "blocking": 2, "async": 4, "defer": 1, "module": 1, "nomodule": 0 },
|
|
478
492
|
"byParty": { "firstParty": 5, "thirdParty": 3 },
|
|
479
493
|
"thirdPartyBlockingCount": 1
|
|
480
494
|
},
|
|
481
495
|
"items": [
|
|
482
496
|
{
|
|
483
|
-
"url": "https://
|
|
497
|
+
"url": "https://example.com/app.js",
|
|
484
498
|
"shortName": "app.js",
|
|
485
499
|
"strategy": "blocking",
|
|
486
500
|
"location": "head",
|
|
@@ -520,7 +534,7 @@ Synchronous. `count` is the number of uncompressed text resources; `items` holds
|
|
|
520
534
|
},
|
|
521
535
|
"items": [
|
|
522
536
|
{
|
|
523
|
-
"url": "https://
|
|
537
|
+
"url": "https://example.com/app.css",
|
|
524
538
|
"shortName": "app.css",
|
|
525
539
|
"type": "css",
|
|
526
540
|
"encoding": "none",
|
|
@@ -538,7 +552,7 @@ Synchronous. `count` is the number of uncompressed text resources; `items` holds
|
|
|
538
552
|
|
|
539
553
|
#### Third-Party-Impact-by-Domain
|
|
540
554
|
|
|
541
|
-
Synchronous. `count` is the number of third-party root domains; `items` holds the 50 to look at first (render-blocking, then the most main-thread time, then the largest transfer), while `details` totals every domain. `loafMs` is the script time attributed to the domain in `long-animation-frame` entries (sum of `scripts[].duration` by `sourceURL`), or `null` for every item when `details.loafSupported` is `false`. `details.longFrames` counts the long animation frames on the page and `details.unattributedLoafMs` is the time in them that no third-party script accounts for (first-party scripts, scripts under the 5 ms that the API lists, and style, layout and rendering work); both are `null` when `loafSupported` is `false`. A cross-origin request without `Timing-Allow-Origin` reports zero for every size, so it is counted in `sizeUnknownCount` (per domain and in `details`), left out of the byte totals, and `corsLimitedAnalysis` is `true`.
|
|
555
|
+
Synchronous. `count` is the number of third-party root domains; `items` holds the 50 to look at first (render-blocking, then the most main-thread time, then the largest transfer), while `details` totals every domain. `loafMs` is the script time attributed to the domain in `long-animation-frame` entries (sum of `scripts[].duration` by `sourceURL`), or `null` for every item when `details.loafSupported` is `false`. `details.longFrames` counts the long animation frames on the page and `details.unattributedLoafMs` is the time in them that no third-party script accounts for (first-party scripts, scripts under the 5 ms that the API lists, and style, layout and rendering work); both are `null` when `loafSupported` is `false`. `details.renderBlockingSupported` is `false` in browsers without `renderBlockingStatus` (only Chromium has it); then `renderBlocking` is `null` for every item, `details.blockingDomains` is `null` and an `info` issue says so. A cross-origin request without `Timing-Allow-Origin` reports zero for every size, so it is counted in `sizeUnknownCount` (per domain and in `details`), left out of the byte totals, and `corsLimitedAnalysis` is `true`.
|
|
542
556
|
|
|
543
557
|
```json
|
|
544
558
|
{
|
|
@@ -554,6 +568,7 @@ Synchronous. `count` is the number of third-party root domains; `items` holds th
|
|
|
554
568
|
"unattributedLoafMs": 74,
|
|
555
569
|
"firstPartyRequests": 1,
|
|
556
570
|
"blockingDomains": 1,
|
|
571
|
+
"renderBlockingSupported": true,
|
|
557
572
|
"loafSupported": true,
|
|
558
573
|
"sizeUnknownCount": 1
|
|
559
574
|
},
|
|
@@ -568,6 +583,37 @@ Synchronous. `count` is the number of third-party root domains; `items` holds th
|
|
|
568
583
|
}
|
|
569
584
|
```
|
|
570
585
|
|
|
586
|
+
#### First-And-Third-Party-Script-Info
|
|
587
|
+
|
|
588
|
+
Synchronous. A script is a resource with `initiatorType` `script`, or one whose URL matches a `<script src>`, `<link rel="preload" as="script">` or `<link rel="modulepreload">` in the document (a preloaded script has `initiatorType` `link` or `other`). Each URL counts once; when the browser fetches it twice, the entry of the `<script>` is kept. `items` holds the 50 scripts to look at first (render-blocking, then third-party, then the largest). `details.renderBlockingSupported` is `false` in browsers without `renderBlockingStatus` (only Chromium has it); then `renderBlocking` is `null` for every item, `details.thirdPartyBlockingCount` is `null` and an `info` issue says so.
|
|
589
|
+
|
|
590
|
+
```json
|
|
591
|
+
{
|
|
592
|
+
"script": "First-And-Third-Party-Script-Info",
|
|
593
|
+
"status": "ok",
|
|
594
|
+
"count": 3,
|
|
595
|
+
"corsLimitedAnalysis": true,
|
|
596
|
+
"details": {
|
|
597
|
+
"firstPartyCount": 1,
|
|
598
|
+
"thirdPartyCount": 2,
|
|
599
|
+
"thirdPartyPercent": 67,
|
|
600
|
+
"firstPartySizeBytes": 48000,
|
|
601
|
+
"thirdPartySizeBytes": 0,
|
|
602
|
+
"thirdPartyBlockingCount": 1,
|
|
603
|
+
"thirdPartyHostCount": 2,
|
|
604
|
+
"sizeUnknownCount": 2,
|
|
605
|
+
"renderBlockingSupported": true
|
|
606
|
+
},
|
|
607
|
+
"items": [
|
|
608
|
+
{ "shortName": "tag.js", "host": "tags.example", "firstParty": false, "transferBytes": 0, "sizeKnown": false, "durationMs": 120, "renderBlocking": true }
|
|
609
|
+
],
|
|
610
|
+
"issues": [
|
|
611
|
+
{ "severity": "info", "message": "2 script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound" },
|
|
612
|
+
{ "severity": "error", "message": "1 render-blocking third-party script(s)" }
|
|
613
|
+
]
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
571
617
|
#### Service-Worker-Analysis
|
|
572
618
|
|
|
573
619
|
Async. Returns one item per registration. `cacheHitRate` is computed over the resources whose source is known: a cross-origin resource without `Timing-Allow-Origin` reports zero for every size, so it is counted in `fromUnknown` and left out of the rate. `rating` is present only when a rate exists, and `cacheStorage` lists the 20 largest caches (`cacheStorageCount` is the total). With no registration the script returns `status: "ok"`, `count: 0` and an info issue.
|
|
@@ -595,8 +641,8 @@ Async. Returns one item per registration. `cacheHitRate` is computed over the re
|
|
|
595
641
|
},
|
|
596
642
|
"items": [
|
|
597
643
|
{
|
|
598
|
-
"scope": "https://
|
|
599
|
-
"scriptURL": "https://
|
|
644
|
+
"scope": "https://example.com/",
|
|
645
|
+
"scriptURL": "https://example.com/sw.js",
|
|
600
646
|
"state": "active",
|
|
601
647
|
"hasWaiting": false,
|
|
602
648
|
"hasInstalling": false,
|
|
@@ -644,7 +690,7 @@ Async. Returns one item per registration. `cacheHitRate` is computed over the re
|
|
|
644
690
|
|
|
645
691
|
#### FCP
|
|
646
692
|
|
|
647
|
-
`value` is measured from `activationStart` on prerendered pages. `details` splits it into three phases that add up to `value`: TTFB, the wait for the last render-blocking resource, and the rest until the paint. `items` lists the render-blocking resources, the last to finish first (at most 50).
|
|
693
|
+
`value` is measured from `activationStart` on prerendered pages. `details` splits it into three phases that add up to `value`: TTFB, the wait for the last render-blocking resource, and the rest until the paint. `items` lists the render-blocking resources, the last to finish first (at most 50). `details.renderBlockingSupported` is `false` in browsers without `renderBlockingStatus` (only Chromium has it): which resources block rendering is unknown, so `renderBlockingLoadMs`, `renderDelayMs` and `blockingResourceCount` are `null`, `items` is empty, and `value` and `ttfbMs` are still measured.
|
|
648
694
|
|
|
649
695
|
```json
|
|
650
696
|
{
|
|
@@ -655,7 +701,7 @@ Async. Returns one item per registration. `cacheHitRate` is computed over the re
|
|
|
655
701
|
"unit": "ms",
|
|
656
702
|
"rating": "good",
|
|
657
703
|
"thresholds": { "good": 1800, "needsImprovement": 3000 },
|
|
658
|
-
"details": { "ttfbMs": 180, "renderBlockingLoadMs": 620, "renderDelayMs": 440, "blockingResourceCount": 2 },
|
|
704
|
+
"details": { "ttfbMs": 180, "renderBlockingLoadMs": 620, "renderDelayMs": 440, "blockingResourceCount": 2, "renderBlockingSupported": true },
|
|
659
705
|
"items": [{ "url": "app.css", "type": "CSS", "durationMs": 310 }]
|
|
660
706
|
}
|
|
661
707
|
```
|
|
@@ -911,7 +957,7 @@ Immediately returns the buffered CLS (the largest session window), plus a summar
|
|
|
911
957
|
|
|
912
958
|
#### Long-Animation-Frames
|
|
913
959
|
|
|
914
|
-
Returns buffered LoAF data immediately. Ongoing tracking continues. `items` holds the 20 frames with the most blocking time, in the same shape at start and in `getLoAFSummary()`, each with its 10 slowest scripts.
|
|
960
|
+
Returns buffered LoAF data immediately. Ongoing tracking continues. `items` holds the 20 frames with the most blocking time, in the same shape at start and in `getLoAFSummary()`, each with its 10 slowest scripts. In browsers without `long-animation-frame` entries (Firefox, Safari) the script and `getLoAFSummary()` return `{ "script": "Long-Animation-Frames", "status": "unsupported", "error": "..." }`, not a count of 0.
|
|
915
961
|
|
|
916
962
|
```json
|
|
917
963
|
{
|
|
@@ -1148,7 +1194,7 @@ Same shape as Image-Element-Audit but for video elements.
|
|
|
1148
1194
|
### Reading results
|
|
1149
1195
|
|
|
1150
1196
|
```
|
|
1151
|
-
//
|
|
1197
|
+
// Read the return value: the skill scripts have no console output
|
|
1152
1198
|
result = evaluate_script(scriptCode)
|
|
1153
1199
|
if result.status == "ok" → use result.value, result.rating, result.details, result.items
|
|
1154
1200
|
if result.status == "tracking" → call evaluate_script(`${result.getDataFn}()`) after user interaction
|
|
@@ -1195,4 +1241,4 @@ data = evaluate_script("getINP()")
|
|
|
1195
1241
|
11. **Hidden cross-origin data is reported, not guessed** — a resource whose timing or size is hidden (no `Timing-Allow-Origin`) reports zeros for every size and timing. Count it (`sizeKnown: false`, `corsRestrictedCount`) instead of treating the zero as a measurement.
|
|
1196
1242
|
12. **Partial analysis is flagged** — when hidden cross-origin data leaves part of the set unanalyzed, set the top-level `corsLimitedAnalysis: true` and report how many resources were skipped in `details` (`sizeUnknownCount`, `corsRestrictedCount`). Set it to `false` when every resource was analyzed.
|
|
1197
1243
|
|
|
1198
|
-
The rules are checked by `cli/tests/e2e/snippet-contract.test.js`, which runs every script, and every `
|
|
1244
|
+
The rules are checked by `cli/tests/e2e/snippet-contract.test.js`, which runs every script, its `getDataFn` and every other getter it exposes on `window` (such as `getINPDetails()` and `checkBfcache()`), on an empty page, a seeded page and a heavy page.
|
|
@@ -85,6 +85,70 @@ function isOwnRequest(entry) {
|
|
|
85
85
|
}
|
|
86
86
|
// @end-shared isOwnRequest
|
|
87
87
|
|
|
88
|
+
// @shared createInteractionTracker
|
|
89
|
+
// Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
|
|
90
|
+
// interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
|
|
91
|
+
// interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
|
|
92
|
+
// durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
|
|
93
|
+
// down, has its own timing. It is reported under the event whose handler ran longest, or under the
|
|
94
|
+
// click or keydown when no handler took a millisecond more than theirs.
|
|
95
|
+
function createInteractionTracker() {
|
|
96
|
+
const byId = new Map();
|
|
97
|
+
const processing = (e) => e.processingEnd - e.processingStart;
|
|
98
|
+
const measure = (id, entries) => {
|
|
99
|
+
const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
|
|
100
|
+
const paint = longest.startTime + longest.duration;
|
|
101
|
+
const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
|
|
102
|
+
const startTime = Math.min(...frame.map((e) => e.startTime));
|
|
103
|
+
const processingStart = Math.min(...frame.map((e) => e.processingStart));
|
|
104
|
+
const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
|
|
105
|
+
const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
|
|
106
|
+
const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
|
|
107
|
+
const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
|
|
108
|
+
return {
|
|
109
|
+
interactionId: id,
|
|
110
|
+
name: reported.name,
|
|
111
|
+
target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
|
|
112
|
+
startTime,
|
|
113
|
+
duration: longest.duration,
|
|
114
|
+
phases: {
|
|
115
|
+
inputDelay: processingStart - startTime,
|
|
116
|
+
processingTime: processingEnd - processingStart,
|
|
117
|
+
presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
// Adds the entries of one observer callback and returns the interactions they changed
|
|
123
|
+
add(entries) {
|
|
124
|
+
const changed = new Set();
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (!e.interactionId) continue;
|
|
127
|
+
const record = byId.get(e.interactionId) ?? { entries: [] };
|
|
128
|
+
record.entries.push({
|
|
129
|
+
name: e.name,
|
|
130
|
+
target: e.target,
|
|
131
|
+
startTime: e.startTime,
|
|
132
|
+
duration: e.duration,
|
|
133
|
+
processingStart: e.processingStart,
|
|
134
|
+
processingEnd: e.processingEnd,
|
|
135
|
+
});
|
|
136
|
+
byId.set(e.interactionId, record);
|
|
137
|
+
changed.add(e.interactionId);
|
|
138
|
+
}
|
|
139
|
+
return [...changed].map((id) => {
|
|
140
|
+
const record = byId.get(id);
|
|
141
|
+
record.summary = measure(id, record.entries);
|
|
142
|
+
return record.summary;
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
list() {
|
|
146
|
+
return [...byId.values()].map((record) => record.summary);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// @end-shared createInteractionTracker
|
|
151
|
+
|
|
88
152
|
// @shared uniqueSelector
|
|
89
153
|
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
90
154
|
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
@@ -118,3 +182,11 @@ function uniqueSelector(el) {
|
|
|
118
182
|
return parts.join(" > ");
|
|
119
183
|
}
|
|
120
184
|
// @end-shared uniqueSelector
|
|
185
|
+
|
|
186
|
+
// @shared supportsRenderBlockingStatus
|
|
187
|
+
// renderBlockingStatus (Resource Timing) is Chromium-only (Chrome 107+). Without it, which
|
|
188
|
+
// resources block rendering is unknown: a snippet reports that, not "none".
|
|
189
|
+
function supportsRenderBlockingStatus() {
|
|
190
|
+
return !!window.PerformanceResourceTiming && "renderBlockingStatus" in window.PerformanceResourceTiming.prototype;
|
|
191
|
+
}
|
|
192
|
+
// @end-shared supportsRenderBlockingStatus
|