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
package/README.md
CHANGED
|
@@ -35,44 +35,52 @@ npx webperf-snippets <url> [options]
|
|
|
35
35
|
Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP > 2.5s):
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
npx webperf-snippets https://
|
|
38
|
+
npx webperf-snippets https://example.com
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings, third-party impact, fonts, cache; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
npx webperf-snippets https://
|
|
44
|
+
npx webperf-snippets https://example.com --workflow loading
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
The steps that tell first-party from third-party resources count as first party the root domain of the page and its subdomains (`cdn.example.com` for `example.com`). A site that serves its own assets from another domain, such as its CDN, lists it with `--own-domains`, comma-separated or repeated:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net,static.example.org
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each domain counts with its root domain, so `cdn.example.net` makes every host under `example.net` first party. The CLI writes the list in the `OWN_DOMAINS` constant of each snippet, the same one you edit at the top of the snippet in the console. An empty domain or one with spaces exits with code `2`.
|
|
54
|
+
|
|
47
55
|
Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
|
|
48
56
|
|
|
49
57
|
```bash
|
|
50
|
-
npx webperf-snippets https://
|
|
58
|
+
npx webperf-snippets https://example.com --workflow audit
|
|
51
59
|
```
|
|
52
60
|
|
|
53
61
|
Markdown output for PR comments:
|
|
54
62
|
|
|
55
63
|
```bash
|
|
56
|
-
npx webperf-snippets https://
|
|
64
|
+
npx webperf-snippets https://example.com --markdown
|
|
57
65
|
```
|
|
58
66
|
|
|
59
67
|
JSON output (for piping into `jq` or CI):
|
|
60
68
|
|
|
61
69
|
```bash
|
|
62
|
-
npx webperf-snippets https://
|
|
70
|
+
npx webperf-snippets https://example.com --json
|
|
63
71
|
```
|
|
64
72
|
|
|
65
73
|
Single snippet:
|
|
66
74
|
|
|
67
75
|
```bash
|
|
68
|
-
npx webperf-snippets https://
|
|
76
|
+
npx webperf-snippets https://example.com --snippet LCP-Subparts
|
|
69
77
|
```
|
|
70
78
|
|
|
71
79
|
Synthetic INP measurement with an interaction script:
|
|
72
80
|
|
|
73
81
|
```bash
|
|
74
|
-
npx webperf-snippets https://
|
|
75
|
-
npx webperf-snippets https://
|
|
82
|
+
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
83
|
+
npx webperf-snippets https://example.com --snippet Interaction/Interactions --interact-script interactions.json
|
|
76
84
|
```
|
|
77
85
|
|
|
78
86
|
The Interaction snippets (`Interactions`, `Input-Latency-Breakdown`, `LongTask`, `Forced-Synchronous-Layout`...) are installed before the first step and read after the last one, so their result holds what the steps caused.
|
|
@@ -82,7 +90,7 @@ The CLI waits for the browser to report the timing of the last interaction befor
|
|
|
82
90
|
Interaction audit (long tasks, long animation frames, scroll, layout shifts, forced layouts, interaction latency). It scrolls the page, or runs your `--interact-script`; the steps that need a click are skipped when the script has none:
|
|
83
91
|
|
|
84
92
|
```bash
|
|
85
|
-
npx webperf-snippets https://
|
|
93
|
+
npx webperf-snippets https://example.com --workflow interaction
|
|
86
94
|
```
|
|
87
95
|
|
|
88
96
|
Pages behind a login, with a Playwright storage state (cookies and localStorage):
|
|
@@ -97,10 +105,20 @@ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
|
97
105
|
|
|
98
106
|
The file holds live session cookies. Keep it out of version control (add it to `.gitignore`) and use a throwaway account when you run it in CI. The CLI exits with code `2` if the path does not exist.
|
|
99
107
|
|
|
108
|
+
Pages behind an access proxy that reads a token from every request, such as the service tokens of Cloudflare Access, take the token as request headers with `--header`, once per header:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx webperf-snippets https://review.example.com \
|
|
112
|
+
--header "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
|
|
113
|
+
--header "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The headers go with every request to the origin of the URL and never to another origin, so third parties do not receive the token and their cross-origin requests need no CORS preflight. Each request to that origin is paused in the browser to add them, which adds a few milliseconds to its timing; when the proxy accepts a session cookie, `--storage-state` measures without that cost. A header without a name or a colon exits with code `2`. Read the values from environment variables, as above, to keep them out of the shell history.
|
|
117
|
+
|
|
100
118
|
Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
|
|
101
119
|
|
|
102
120
|
```bash
|
|
103
|
-
CRUX_API_KEY=your-key npx webperf-snippets https://
|
|
121
|
+
CRUX_API_KEY=your-key npx webperf-snippets https://example.com
|
|
104
122
|
```
|
|
105
123
|
|
|
106
124
|
```
|
|
@@ -134,20 +152,36 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
134
152
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
135
153
|
"navMs": 1240,
|
|
136
154
|
"results": [],
|
|
137
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
155
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.2.0" }
|
|
138
156
|
}
|
|
139
157
|
```
|
|
140
158
|
|
|
141
|
-
For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
|
|
159
|
+
For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. With `--cpu` or `--network`, `meta` carries the conditions in `throttling`. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
|
|
142
160
|
|
|
143
161
|
- The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
|
|
144
162
|
- `--api-key` is sent as `Authorization: Bearer <key>`. Prefer the `PERF_REVIEWS_API_KEY` environment variable, because a flag ends up in shell history and CI logs. The key is never printed.
|
|
145
163
|
- The exit code never depends on the report. A failed POST (error status, unreachable endpoint, or no answer within 5 seconds) prints a warning and the exit code stays the one the budgets and snippets decide. If the endpoint answers `{ "ok": false, "regressions": [...] }`, the regressions are printed as a warning, again without changing the exit code.
|
|
146
164
|
|
|
165
|
+
Measure under the conditions of a slower device and network with `--cpu` and `--network`. Without them the CLI runs at the speed of the machine, which on a laptop or a CI runner is faster than most phones:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`--cpu <factor>` slows the main thread down by that factor. `--network` takes the presets of DevTools, with the same values:
|
|
172
|
+
|
|
173
|
+
| Preset | Download | Upload | Latency |
|
|
174
|
+
| --------- | ----------- | ----------- | --------- |
|
|
175
|
+
| `fast-4g` | 8.1 Mbps | 1.35 Mbps | 165 ms |
|
|
176
|
+
| `slow-4g` | 1.44 Mbps | 675 kbps | 562.5 ms |
|
|
177
|
+
| `3g` | 400 kbps | 400 kbps | 2000 ms |
|
|
178
|
+
|
|
179
|
+
Chrome adds the latency to the body of each response, not to its headers. The values read from `responseStart`, such as the TTFB and its sub-parts, do not include it; the download of each resource, FCP, LCP and the load event do. The report names the conditions under its header, and the JSON output carries them in `throttling`, such as `{ "cpu": 4, "network": "slow-4g" }`. A CPU factor below `1` or an unknown preset exits with code `2`.
|
|
180
|
+
|
|
147
181
|
CI gating:
|
|
148
182
|
|
|
149
183
|
```bash
|
|
150
|
-
npx webperf-snippets https://
|
|
184
|
+
npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
151
185
|
```
|
|
152
186
|
|
|
153
187
|
### Options
|
|
@@ -160,8 +194,12 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
160
194
|
| `--markdown` | Output GitHub-renderable markdown (for PR comments). |
|
|
161
195
|
| `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
|
|
162
196
|
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
|
|
197
|
+
| `--cpu <factor>` | Slow the main thread down by this factor, such as `4`. No throttling by default. |
|
|
198
|
+
| `--network <preset>` | Throttle the network with a preset of DevTools: `fast-4g`, `slow-4g`, `3g`. No throttling by default. |
|
|
163
199
|
| `--interact-script <path>` | JSON file with interactions to run after the page loads (`scroll`, `click`, `hover`, `type`, `wait`). The Interaction snippets are installed first and read afterwards. |
|
|
164
200
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
201
|
+
| `--header "Name: value"` | Request header for the origin of the URL only, such as the token of an access proxy. Repeat it for more headers. |
|
|
202
|
+
| `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
|
|
165
203
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
166
204
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
167
205
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -204,6 +242,47 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
204
242
|
| `1` | Budget violation, a snippet errored, or a step of the workflow reported an `error` issue. |
|
|
205
243
|
| `2` | Usage error (missing URL, unknown workflow). |
|
|
206
244
|
|
|
245
|
+
## MCP server
|
|
246
|
+
|
|
247
|
+
`webperf-snippets mcp` starts a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so an AI agent can call the snippets by name instead of reading their source. Add it to a client that launches local servers, for example Claude Code:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
claude mcp add webperf -- npx -y webperf-snippets mcp
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
It needs the Chromium of Playwright (`npx playwright install chromium`); without it, the tools answer with that command. The [MCP server documentation](https://webperf-snippets.net/MCP) has the configuration of other clients (Cursor, VS Code, Claude Desktop, Gemini CLI). The server is listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.nucliweb/webperf-snippets`.
|
|
254
|
+
|
|
255
|
+
| Tool | What it does |
|
|
256
|
+
| --------------- | ---------------------------------------------------------------------------- |
|
|
257
|
+
| `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
|
|
258
|
+
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns the object it returns. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `waitMs`. The snippets that record interactions run on a page opened with `open_page`. |
|
|
259
|
+
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport` and `waitMs`. |
|
|
260
|
+
| `get_crux` | Returns the CrUX p75 of LCP, CLS and INP for a URL, or its origin. Takes `url` and an optional `formFactor` (`phone`, `desktop`, `tablet`). Needs `CRUX_API_KEY` in the environment of the server. |
|
|
261
|
+
| `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
|
|
262
|
+
| `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
|
|
263
|
+
| `read_results` | Returns what the tracking snippets of an open page have recorded. |
|
|
264
|
+
| `close_page` | Closes an open page. |
|
|
265
|
+
|
|
266
|
+
The server also offers resources, documents a client can attach to the context:
|
|
267
|
+
|
|
268
|
+
| Resource | What it has |
|
|
269
|
+
| -------------------------------- | ------------------------------------------------------------------------ |
|
|
270
|
+
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result. |
|
|
271
|
+
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example. |
|
|
272
|
+
| `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
|
|
273
|
+
|
|
274
|
+
And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
|
|
275
|
+
|
|
276
|
+
The CrUX key goes in the configuration of the client, never in a tool argument:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
claude mcp add webperf -e CRUX_API_KEY=<key> -- npx -y webperf-snippets mcp
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
At most three pages are open at a time, a page closes after 10 minutes without use, and every page and Chromium close when the client disconnects.
|
|
283
|
+
|
|
284
|
+
The server answers clients of both eras of the protocol: the 2026-07-28 revision and the earlier ones that open with `initialize`. [ADR 0002](../docs/adr/0002-mcp-server-as-cli-subcommand.md) explains why it is a subcommand and uses stdio.
|
|
285
|
+
|
|
207
286
|
## CI example
|
|
208
287
|
|
|
209
288
|
GitHub Actions, fail the PR if LCP exceeds 2.5s:
|
|
@@ -212,7 +291,7 @@ GitHub Actions, fail the PR if LCP exceeds 2.5s:
|
|
|
212
291
|
- run: |
|
|
213
292
|
npm install --no-save webperf-snippets playwright
|
|
214
293
|
npx playwright install --with-deps chromium
|
|
215
|
-
npx webperf-snippets https://staging.
|
|
294
|
+
npx webperf-snippets https://staging.example.com --budget-lcp 2500 --budget-cls 0.1
|
|
216
295
|
```
|
|
217
296
|
|
|
218
297
|
### GitHub Action
|
|
@@ -308,9 +387,9 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
308
387
|
## How it works
|
|
309
388
|
|
|
310
389
|
1. Launches headless chromium via Playwright.
|
|
311
|
-
2.
|
|
312
|
-
3.
|
|
313
|
-
4.
|
|
390
|
+
2. Navigates to the URL, waits for the `load` event and then for `--wait` milliseconds (3000 by default), so late entries reach the page.
|
|
391
|
+
3. Evaluates each snippet's IIFE in the page context, capturing the returned object. The snippets read the entries Chrome only exposes to observers (LCP, layout shifts, long tasks, event timing) with a buffered `PerformanceObserver`, so they see the entries recorded before they ran.
|
|
392
|
+
4. With an interaction script, installs the tracking snippets first, runs the interactions, lets the page settle and then reads the tracking data and runs the other snippets.
|
|
314
393
|
5. Applies the workflow's decision tree to enqueue follow-up snippets.
|
|
315
394
|
6. Renders results (human or JSON) and exits with an appropriate code.
|
|
316
395
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
4
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": "./src/bin.js",
|
|
@@ -15,8 +16,12 @@
|
|
|
15
16
|
"test:unit": "vitest run tests/unit",
|
|
16
17
|
"test:e2e": "vitest run tests/e2e"
|
|
17
18
|
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/server": "^2.3.1"
|
|
21
|
+
},
|
|
18
22
|
"devDependencies": {
|
|
19
|
-
"
|
|
23
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
|
+
"vitest": "^5.0.5"
|
|
20
25
|
},
|
|
21
26
|
"engines": {
|
|
22
27
|
"node": ">=20.12.0"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
console.warn("⚠️ event entries are not supported in this browser.");
|
|
7
7
|
return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
let interactions = [];
|
|
10
10
|
let inpValue = 0;
|
|
11
11
|
let inpEntry = null;
|
|
12
12
|
|
|
@@ -21,6 +21,104 @@
|
|
|
21
21
|
|
|
22
22
|
const formatMs = (ms) => `${Math.round(ms)}ms`;
|
|
23
23
|
|
|
24
|
+
// @shared createInteractionTracker
|
|
25
|
+
// Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
|
|
26
|
+
// interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
|
|
27
|
+
// interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
|
|
28
|
+
// durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
|
|
29
|
+
// down, has its own timing. It is reported under the event whose handler ran longest, or under the
|
|
30
|
+
// click or keydown when no handler took a millisecond more than theirs.
|
|
31
|
+
function createInteractionTracker() {
|
|
32
|
+
const byId = new Map();
|
|
33
|
+
const processing = (e) => e.processingEnd - e.processingStart;
|
|
34
|
+
const measure = (id, entries) => {
|
|
35
|
+
const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
|
|
36
|
+
const paint = longest.startTime + longest.duration;
|
|
37
|
+
const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
|
|
38
|
+
const startTime = Math.min(...frame.map((e) => e.startTime));
|
|
39
|
+
const processingStart = Math.min(...frame.map((e) => e.processingStart));
|
|
40
|
+
const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
|
|
41
|
+
const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
|
|
42
|
+
const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
|
|
43
|
+
const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
|
|
44
|
+
return {
|
|
45
|
+
interactionId: id,
|
|
46
|
+
name: reported.name,
|
|
47
|
+
target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
|
|
48
|
+
startTime,
|
|
49
|
+
duration: longest.duration,
|
|
50
|
+
phases: {
|
|
51
|
+
inputDelay: processingStart - startTime,
|
|
52
|
+
processingTime: processingEnd - processingStart,
|
|
53
|
+
presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
// Adds the entries of one observer callback and returns the interactions they changed
|
|
59
|
+
add(entries) {
|
|
60
|
+
const changed = new Set();
|
|
61
|
+
for (const e of entries) {
|
|
62
|
+
if (!e.interactionId) continue;
|
|
63
|
+
const record = byId.get(e.interactionId) ?? { entries: [] };
|
|
64
|
+
record.entries.push({
|
|
65
|
+
name: e.name,
|
|
66
|
+
target: e.target,
|
|
67
|
+
startTime: e.startTime,
|
|
68
|
+
duration: e.duration,
|
|
69
|
+
processingStart: e.processingStart,
|
|
70
|
+
processingEnd: e.processingEnd,
|
|
71
|
+
});
|
|
72
|
+
byId.set(e.interactionId, record);
|
|
73
|
+
changed.add(e.interactionId);
|
|
74
|
+
}
|
|
75
|
+
return [...changed].map((id) => {
|
|
76
|
+
const record = byId.get(id);
|
|
77
|
+
record.summary = measure(id, record.entries);
|
|
78
|
+
return record.summary;
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
list() {
|
|
82
|
+
return [...byId.values()].map((record) => record.summary);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// @end-shared createInteractionTracker
|
|
87
|
+
|
|
88
|
+
// @shared uniqueSelector
|
|
89
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
90
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
91
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
92
|
+
function uniqueSelector(el) {
|
|
93
|
+
const root = el.getRootNode();
|
|
94
|
+
const matchesOne = (selector) => {
|
|
95
|
+
try {
|
|
96
|
+
return root.querySelectorAll(selector).length === 1;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const parts = [];
|
|
102
|
+
let node = el;
|
|
103
|
+
while (node && node.nodeType === 1) {
|
|
104
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
105
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const tag = node.localName;
|
|
109
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
110
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
111
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
112
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
113
|
+
}
|
|
114
|
+
parts.unshift(part);
|
|
115
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
116
|
+
node = node.parentElement;
|
|
117
|
+
}
|
|
118
|
+
return parts.join(" > ");
|
|
119
|
+
}
|
|
120
|
+
// @end-shared uniqueSelector
|
|
121
|
+
|
|
24
122
|
// Calculate INP (98th percentile of all interactions)
|
|
25
123
|
const calculateINP = () => {
|
|
26
124
|
if (interactions.length === 0) return { value: 0, entry: null };
|
|
@@ -54,64 +152,18 @@
|
|
|
54
152
|
return `${entry.name} → ${selector}`;
|
|
55
153
|
};
|
|
56
154
|
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (entry.processingStart && entry.processingEnd) {
|
|
66
|
-
phases.inputDelay = entry.processingStart - entry.startTime;
|
|
67
|
-
phases.processingTime = entry.processingEnd - entry.processingStart;
|
|
68
|
-
phases.presentationDelay = Math.max(
|
|
69
|
-
0,
|
|
70
|
-
entry.duration - phases.inputDelay - phases.processingTime
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return phases;
|
|
155
|
+
// One record per interaction (interactionId), with its phases measured over all its entries
|
|
156
|
+
const tracker = createInteractionTracker();
|
|
157
|
+
const addEntries = (entries) => {
|
|
158
|
+
tracker.add(entries);
|
|
159
|
+
interactions = tracker.list().map((i) => ({ ...i, formattedName: getInteractionName(i) }));
|
|
160
|
+
const result = calculateINP();
|
|
161
|
+
inpValue = result.value;
|
|
162
|
+
inpEntry = result.entry;
|
|
75
163
|
};
|
|
76
164
|
|
|
77
165
|
// Observer for interactions
|
|
78
|
-
const observer = new PerformanceObserver((list) =>
|
|
79
|
-
for (const entry of list.getEntries()) {
|
|
80
|
-
// Only track interactions with interactionId (meaningful interactions)
|
|
81
|
-
if (!entry.interactionId) continue;
|
|
82
|
-
|
|
83
|
-
// Avoid duplicate entries for the same interaction
|
|
84
|
-
const existing = interactions.find(
|
|
85
|
-
(i) => i.interactionId === entry.interactionId
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
if (!existing || entry.duration > existing.duration) {
|
|
89
|
-
// Remove old entry if exists
|
|
90
|
-
if (existing) {
|
|
91
|
-
const idx = interactions.indexOf(existing);
|
|
92
|
-
interactions.splice(idx, 1);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
interactions.push({
|
|
96
|
-
name: entry.name,
|
|
97
|
-
duration: entry.duration,
|
|
98
|
-
startTime: entry.startTime,
|
|
99
|
-
interactionId: entry.interactionId,
|
|
100
|
-
target: entry.target,
|
|
101
|
-
processingStart: entry.processingStart,
|
|
102
|
-
processingEnd: entry.processingEnd,
|
|
103
|
-
formattedName: getInteractionName(entry),
|
|
104
|
-
phases: getPhaseBreakdown(entry),
|
|
105
|
-
entry,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Recalculate INP
|
|
110
|
-
const result = calculateINP();
|
|
111
|
-
inpValue = result.value;
|
|
112
|
-
inpEntry = result.entry;
|
|
113
|
-
}
|
|
114
|
-
});
|
|
166
|
+
const observer = new PerformanceObserver((list) => addEntries(list.getEntries()));
|
|
115
167
|
|
|
116
168
|
// Observe event timing
|
|
117
169
|
observer.observe({
|
|
@@ -324,7 +376,13 @@
|
|
|
324
376
|
if (interactions.length === 0) {
|
|
325
377
|
console.log(" No interactions recorded yet.");
|
|
326
378
|
console.groupEnd();
|
|
327
|
-
return
|
|
379
|
+
return {
|
|
380
|
+
script: "INP",
|
|
381
|
+
status: "error",
|
|
382
|
+
error: "No interactions recorded yet. Interact with the page and call getINPDetails() again.",
|
|
383
|
+
count: 0,
|
|
384
|
+
items: [],
|
|
385
|
+
};
|
|
328
386
|
}
|
|
329
387
|
|
|
330
388
|
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
|
|
@@ -401,16 +459,30 @@
|
|
|
401
459
|
}
|
|
402
460
|
|
|
403
461
|
console.groupEnd();
|
|
404
|
-
|
|
462
|
+
// The 15 slowest, as plain data: the target is a selector, not the element
|
|
463
|
+
return {
|
|
464
|
+
script: "INP",
|
|
465
|
+
status: "ok",
|
|
466
|
+
count: sorted.length,
|
|
467
|
+
items: sorted.slice(0, maxToShow).map((i) => ({
|
|
468
|
+
formattedName: i.formattedName,
|
|
469
|
+
eventType: i.name,
|
|
470
|
+
target: i.target ? uniqueSelector(i.target) : null,
|
|
471
|
+
duration: Math.round(i.duration),
|
|
472
|
+
startTime: Math.round(i.startTime),
|
|
473
|
+
phases: {
|
|
474
|
+
inputDelay: Math.round(i.phases.inputDelay),
|
|
475
|
+
processingTime: Math.round(i.phases.processingTime),
|
|
476
|
+
presentationDelay: Math.round(i.phases.presentationDelay),
|
|
477
|
+
},
|
|
478
|
+
})),
|
|
479
|
+
};
|
|
405
480
|
};
|
|
406
481
|
|
|
407
482
|
// Log on page hide (final INP)
|
|
408
483
|
document.addEventListener("visibilitychange", () => {
|
|
409
484
|
if (document.visibilityState === "hidden") {
|
|
410
|
-
observer.takeRecords();
|
|
411
|
-
const result = calculateINP();
|
|
412
|
-
inpValue = result.value;
|
|
413
|
-
inpEntry = result.entry;
|
|
485
|
+
addEntries(observer.takeRecords());
|
|
414
486
|
|
|
415
487
|
console.log("%c📊 Final INP (on page hide):", "font-weight: bold;");
|
|
416
488
|
logINP();
|
|
@@ -18,6 +18,26 @@
|
|
|
18
18
|
|
|
19
19
|
const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
|
|
20
20
|
|
|
21
|
+
// Chrome divides the encoded size by the area the image is displayed at, in CSS pixels (not
|
|
22
|
+
// device pixels), and an upscaled image by its natural area instead, so stretching an image never
|
|
23
|
+
// lowers its bits per pixel. Chrome uses the part inside the viewport; this uses the whole box.
|
|
24
|
+
// https://w3c.github.io/largest-contentful-paint/ (steps 8.9 to 8.13) and Chromium's
|
|
25
|
+
// LargestContentfulPaintCalculator::ComputeEffectiveVisualSize
|
|
26
|
+
function displayedSize(img) {
|
|
27
|
+
const rect = img.getBoundingClientRect();
|
|
28
|
+
const style = getComputedStyle(img);
|
|
29
|
+
const px = (prop) => parseFloat(style[prop]) || 0;
|
|
30
|
+
return {
|
|
31
|
+
width: Math.max(0, rect.width - px("borderLeftWidth") - px("borderRightWidth") - px("paddingLeft") - px("paddingRight")),
|
|
32
|
+
height: Math.max(0, rect.height - px("borderTopWidth") - px("borderBottomWidth") - px("paddingTop") - px("paddingBottom")),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function bitsPerPixel(img, fileSize) {
|
|
36
|
+
const displayed = displayedSize(img);
|
|
37
|
+
const pixels = Math.min(displayed.width * displayed.height, img.naturalWidth * img.naturalHeight);
|
|
38
|
+
return { displayed, bpp: pixels > 0 ? (fileSize * 8) / pixels : 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
// Get current LCP element
|
|
22
42
|
let lcpElement = null;
|
|
23
43
|
let lcpUrl = null;
|
|
@@ -47,8 +67,7 @@
|
|
|
47
67
|
const src = img.currentSrc || img.src;
|
|
48
68
|
const resource = performance.getEntriesByName(src)[0];
|
|
49
69
|
const fileSize = resource?.encodedBodySize || 0;
|
|
50
|
-
const
|
|
51
|
-
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
|
|
70
|
+
const { displayed, bpp } = bitsPerPixel(img, fileSize);
|
|
52
71
|
|
|
53
72
|
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
|
|
54
73
|
const isLCP = lcpElement === img || lcpUrl === src;
|
|
@@ -59,6 +78,8 @@
|
|
|
59
78
|
shortSrc: src.split("/").pop()?.split("?")[0] || src,
|
|
60
79
|
width: img.naturalWidth,
|
|
61
80
|
height: img.naturalHeight,
|
|
81
|
+
displayedWidth: Math.round(displayed.width),
|
|
82
|
+
displayedHeight: Math.round(displayed.height),
|
|
62
83
|
fileSize,
|
|
63
84
|
bpp,
|
|
64
85
|
isLowEntropy,
|
|
@@ -105,6 +126,7 @@
|
|
|
105
126
|
.map((img) => ({
|
|
106
127
|
Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
|
|
107
128
|
Dimensions: `${img.width}×${img.height}`,
|
|
129
|
+
Displayed: `${img.displayedWidth}×${img.displayedHeight}`,
|
|
108
130
|
Size: formatBytes(img.fileSize),
|
|
109
131
|
BPP: img.bpp.toFixed(4),
|
|
110
132
|
Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
|
|
@@ -166,14 +188,15 @@
|
|
|
166
188
|
const src = img.currentSrc || img.src;
|
|
167
189
|
const resource = performance.getEntriesByName(src)[0];
|
|
168
190
|
const fileSize = resource?.encodedBodySize || 0;
|
|
169
|
-
const
|
|
170
|
-
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
|
|
191
|
+
const { displayed, bpp } = bitsPerPixel(img, fileSize);
|
|
171
192
|
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
|
|
172
193
|
const isLCP = lcpElementSync === img || lcpUrlSync === src;
|
|
173
194
|
return {
|
|
174
195
|
url: src.split("/").pop()?.split("?")[0] || src,
|
|
175
196
|
width: img.naturalWidth,
|
|
176
197
|
height: img.naturalHeight,
|
|
198
|
+
displayedWidth: Math.round(displayed.width),
|
|
199
|
+
displayedHeight: Math.round(displayed.height),
|
|
177
200
|
fileSizeBytes: fileSize,
|
|
178
201
|
bpp: Math.round(bpp * 10000) / 10000,
|
|
179
202
|
isLowEntropy,
|
|
@@ -67,6 +67,15 @@
|
|
|
67
67
|
}
|
|
68
68
|
// @end-shared uniqueSelector
|
|
69
69
|
|
|
70
|
+
// The Resource Load Time splits into the wait for the server, until the first byte of the response,
|
|
71
|
+
// and the download of the body: a slow server and a heavy image need different fixes. Another
|
|
72
|
+
// origin without Timing-Allow-Origin reports responseStart as 0, and then there is no split.
|
|
73
|
+
const splitLoadTime = (resEntry, requestStart, responseEnd, activationStart) => {
|
|
74
|
+
if (!resEntry?.responseStart) return null;
|
|
75
|
+
const firstByte = Math.min(Math.max(requestStart, resEntry.responseStart - activationStart), responseEnd);
|
|
76
|
+
return { waiting: firstByte - requestStart, download: responseEnd - firstByte };
|
|
77
|
+
};
|
|
78
|
+
|
|
70
79
|
const getNavigationEntry = () => {
|
|
71
80
|
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
72
81
|
if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
|
|
@@ -108,6 +117,8 @@
|
|
|
108
117
|
lcpEntry.startTime - activationStart
|
|
109
118
|
);
|
|
110
119
|
|
|
120
|
+
const loadSplit = splitLoadTime(lcpResEntry, lcpRequestStart, lcpResponseEnd, activationStart);
|
|
121
|
+
|
|
111
122
|
const subPartValues = {
|
|
112
123
|
ttfb: ttfb,
|
|
113
124
|
resourceLoadDelay: lcpRequestStart - ttfb,
|
|
@@ -196,6 +207,9 @@
|
|
|
196
207
|
console.log("");
|
|
197
208
|
console.log("%c💡 Optimization Focus:", "font-weight: bold; color: #3b82f6;");
|
|
198
209
|
console.log(` Slowest phase: ${slowest.name} (${formatPercent(slowest.value, lcpRenderTime)})`);
|
|
210
|
+
if (slowest.key === "resourceLoadTime" && loadSplit) {
|
|
211
|
+
console.log(` ${formatMs(loadSplit.waiting)} waiting for the server, ${formatMs(loadSplit.download)} downloading the resource`);
|
|
212
|
+
}
|
|
199
213
|
|
|
200
214
|
if (slowest.key === "ttfb") {
|
|
201
215
|
console.log(" → Use a CDN to reduce latency");
|
|
@@ -205,6 +219,10 @@
|
|
|
205
219
|
console.log(" → Preload the LCP image: <link rel=\"preload\" as=\"image\" href=\"...\">");
|
|
206
220
|
console.log(" → Remove render-blocking resources");
|
|
207
221
|
console.log(" → Inline critical CSS");
|
|
222
|
+
} else if (slowest.key === "resourceLoadTime" && loadSplit && loadSplit.waiting > loadSplit.download) {
|
|
223
|
+
console.log(" → Reduce the server response time of the LCP resource");
|
|
224
|
+
console.log(" → Serve it from a CDN, with Cache-Control headers");
|
|
225
|
+
console.log(" → Check that it is cached, not generated on each request");
|
|
208
226
|
} else if (slowest.key === "resourceLoadTime") {
|
|
209
227
|
console.log(" → Compress and resize the LCP image");
|
|
210
228
|
console.log(" → Use modern formats (WebP, AVIF)");
|
|
@@ -233,7 +251,9 @@
|
|
|
233
251
|
|
|
234
252
|
console.log("");
|
|
235
253
|
console.log("%c📊 Measures added to Performance timeline", "color: #666;");
|
|
236
|
-
console.log(
|
|
254
|
+
console.log(
|
|
255
|
+
' Read them with performance.getEntriesByType("measure"), or run the snippet during a DevTools Performance recording to see them in the Timings track'
|
|
256
|
+
);
|
|
237
257
|
|
|
238
258
|
console.groupEnd();
|
|
239
259
|
});
|
|
@@ -275,6 +295,8 @@
|
|
|
275
295
|
const loadDelayVal = Math.round(lcpRequestStartSync - ttfbSync);
|
|
276
296
|
const loadTimeVal = Math.round(lcpResponseEndSync - lcpRequestStartSync);
|
|
277
297
|
const renderDelayVal = Math.round(lcpRenderTimeSync - lcpResponseEndSync);
|
|
298
|
+
const loadSplitSync = splitLoadTime(lcpResEntrySync, lcpRequestStartSync, lcpResponseEndSync, activationStartSync);
|
|
299
|
+
const waitingVal = loadSplitSync ? Math.min(loadTimeVal, Math.round(loadSplitSync.waiting)) : null;
|
|
278
300
|
const subPartsForRank = [
|
|
279
301
|
{ key: "ttfb", value: ttfbVal },
|
|
280
302
|
{ key: "resourceLoadDelay", value: loadDelayVal },
|
|
@@ -310,7 +332,12 @@
|
|
|
310
332
|
subParts: {
|
|
311
333
|
ttfb: { value: ttfbVal, percent: Math.round((ttfbVal / totalSync) * 100), overTarget: ttfbVal > 800 },
|
|
312
334
|
resourceLoadDelay: { value: loadDelayVal, percent: Math.round((loadDelayVal / totalSync) * 100), overTarget: (loadDelayVal / totalSync) * 100 > 10 },
|
|
313
|
-
resourceLoadTime: {
|
|
335
|
+
resourceLoadTime: {
|
|
336
|
+
value: loadTimeVal,
|
|
337
|
+
percent: Math.round((loadTimeVal / totalSync) * 100),
|
|
338
|
+
overTarget: (loadTimeVal / totalSync) * 100 > 40,
|
|
339
|
+
...(loadSplitSync ? { waiting: waitingVal, download: loadTimeVal - waitingVal } : {}),
|
|
340
|
+
},
|
|
314
341
|
elementRenderDelay: { value: renderDelayVal, percent: Math.round((renderDelayVal / totalSync) * 100), overTarget: (renderDelayVal / totalSync) * 100 > 10 },
|
|
315
342
|
},
|
|
316
343
|
slowestPhase: slowestPhaseSync,
|
|
@@ -34,7 +34,7 @@ When layout shifts are detected or the user asks "debug CLS" or "layout shift is
|
|
|
34
34
|
|
|
35
35
|
When interactions feel slow or the user asks "debug INP" or "slow interactions":
|
|
36
36
|
|
|
37
|
-
1. **INP.js** - Measure overall INP value; call `getINP()` after interactions, `getINPDetails()` for
|
|
37
|
+
1. **INP.js** - Measure overall INP value; call `getINP()` after interactions, `getINPDetails()` for the 15 slowest interactions
|
|
38
38
|
2. **Interactions.js** _(pending — available in webperf-interaction skill)_
|
|
39
39
|
3. **Input-Latency-Breakdown.js** _(pending — available in webperf-interaction skill)_
|
|
40
40
|
4. **Long-Animation-Frames.js** _(pending — available in webperf-interaction skill)_
|
|
@@ -134,7 +134,7 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
134
134
|
3. **webperf-interaction:Long-Animation-Frames.js** (blocking frames)
|
|
135
135
|
4. **webperf-interaction:Long-Animation-Frames-Script-Attribution.js** (culprit scripts)
|
|
136
136
|
- **If specific interaction type is slow (e.g., keyboard)** → Focus analysis on that interaction type
|
|
137
|
-
- **After INP data is collected** → call `getINPDetails()` for the
|
|
137
|
+
- **After INP data is collected** → call `getINPDetails()` for the 15 slowest interactions, slowest first (useful for identifying patterns across multiple slow interactions)
|
|
138
138
|
|
|
139
139
|
### Error Recovery
|
|
140
140
|
|