webperf-snippets 1.11.0 → 1.13.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 +13 -4
- package/package.json +2 -1
- package/snippets/CoreWebVitals/INP.js +20 -4
- package/snippets/CoreWebVitals/LCP-Trail.js +52 -40
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -0
- package/snippets/Interaction/Interactions.js +4 -0
- package/snippets/Loading/CSS-Complexity.js +562 -0
- package/snippets/Loading/WORKFLOWS.md +11 -0
- package/snippets/SCHEMA.md +86 -0
- package/src/baseline.js +33 -5
- package/src/bin.js +8 -1
- package/src/mcp/queue.js +72 -0
- package/src/mcp/server.js +116 -45
- package/src/runner.js +29 -1
- package/src/snippet-names.js +90 -19
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.13.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -171,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
171
171
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
172
172
|
"navMs": 1240,
|
|
173
173
|
"results": [],
|
|
174
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
174
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.13.0" }
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -297,7 +297,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
|
|
|
297
297
|
}
|
|
298
298
|
```
|
|
299
299
|
|
|
300
|
-
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply
|
|
300
|
+
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
|
|
301
301
|
|
|
302
302
|
The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
|
|
303
303
|
|
|
@@ -351,6 +351,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
351
351
|
| `--verbose` | Show all items, including passing checks. |
|
|
352
352
|
| `--headed` | Show the browser window (debug). |
|
|
353
353
|
| `-h, --help` | Show help. |
|
|
354
|
+
| `-v, --version` | Show the version. |
|
|
354
355
|
|
|
355
356
|
### Snippet aliases
|
|
356
357
|
|
|
@@ -360,6 +361,11 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
360
361
|
| `CLS` | CoreWebVitals/CLS |
|
|
361
362
|
| `INP` | CoreWebVitals/INP |
|
|
362
363
|
| `LCP-Subparts` | CoreWebVitals/LCP-Subparts |
|
|
364
|
+
| `Largest-Contentful-Paint` | CoreWebVitals/LCP |
|
|
365
|
+
| `Cumulative-Layout-Shift` | CoreWebVitals/CLS |
|
|
366
|
+
| `Interaction-to-Next-Paint` | CoreWebVitals/INP |
|
|
367
|
+
| `First-Contentful-Paint` | Loading/FCP |
|
|
368
|
+
| `Time-to-First-Byte` | Loading/TTFB |
|
|
363
369
|
| `fonts` | Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold |
|
|
364
370
|
| `render-blocking` | Loading/Find-render-blocking-resources |
|
|
365
371
|
| `resource-hints` | Loading/Resource-Hints-Validation |
|
|
@@ -411,9 +417,11 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
|
|
|
411
417
|
|
|
412
418
|
Each run is a page load plus `waitMs`. With the default wait of 3 seconds, five runs of a workflow take about 20 seconds on a local page, and about a minute on a page that takes a few seconds to load. When the client asks for progress, the server reports the three stages of each run, numbered across the runs. A client sets its own limit on a tool call: the MCP SDK stops waiting after 60 seconds unless it is given a longer `timeout` or `resetTimeoutOnProgress`, and in Claude Code `MCP_TOOL_TIMEOUT` sets the limit. When the client gives up, the run in progress ends and no other starts.
|
|
413
419
|
|
|
420
|
+
An agent often sends several calls in one turn. The calls of a connection that make a page work (`run_snippet`, `run_workflow`, `open_page`, `interact`) run one at a time, in the order they arrive, so they do not compete for the CPU and the turn saves no time. A call keeps its turn only while it runs, so a page left open holds up nothing, and `read_results`, `close_page` and the tools that load no page answer at once. Waiting counts towards the limit of the client; when it asks for progress, a waiting call reports how many calls are ahead, when the queue moves and every 5 seconds. A call the client gives up on leaves the queue and its page never loads; if it was the one measuring, its Chromium closes and the next call starts. A page that stops answering fails its call after 2 minutes per run, plus the `wait` steps of `interact`, and closes. Two clients at the same time, or a client and the CLI, have no queue in common.
|
|
421
|
+
|
|
414
422
|
`run_snippet` and `run_workflow` compare a run with an earlier one, with the verdict of `--baseline`. `baseline` takes the `runId` of an earlier run of the connection, which every run of these two tools returns, or the path of a page the CLI wrote with `--json`, absolute or relative to the working directory of the server. The answer adds a `baseline` field: the one of the JSON of the CLI, with the `runId` or the `file` it compared with, plus `regressions`, the same lines `--fail-on-regression` prints. `thresholds` takes the values of `--threshold`, such as `["LCP=15%", "CLS=0.1"]`. A baseline that cannot be read or a threshold the server does not understand is a tool error, before the page loads, and a baseline of a matrix is refused, since a tool measures one page.
|
|
415
423
|
|
|
416
|
-
With five runs or more a side, a regression needs a Mann-Whitney U p-value under 0.05 on top of its threshold, as in the CLI, and each metric adds `stats` with the p-value, the shift and its 95 % interval. With fewer, a metric compares by its threshold and, against a baseline of several runs, by the range of those runs. A single run can be off by more than a threshold on a page that did not change, so read `warnings`, and measure both sides with `runs: 5`, or `--runs 5` in the CLI, when the verdict matters. An earlier run of the connection works for a change made in the same session, and a file measured with the CLI for a comparison with `main`.
|
|
424
|
+
With five runs or more a side, a regression needs a Mann-Whitney U p-value under 0.05 on top of its threshold, as in the CLI, and each metric adds `stats` with the p-value, the shift and its 95 % interval. With fewer, a metric compares by its threshold and, against a baseline of several runs, by the range of those runs. A single run can be off by more than a threshold on a page that did not change, so read `warnings`, which also names each metric left without statistics and the values on each side after failed runs, and measure both sides with `runs: 5`, or `--runs 5` in the CLI, when the verdict matters. An earlier run of the connection works for a change made in the same session, and a file measured with the CLI for a comparison with `main`.
|
|
417
425
|
|
|
418
426
|
The server also offers resources, documents a client can attach to the context:
|
|
419
427
|
|
|
@@ -533,6 +541,7 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
533
541
|
- **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
|
|
534
542
|
- **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
|
|
535
543
|
- **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions; use RUM for field data.
|
|
544
|
+
- **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds. When the document recorded no interaction at all, as with a click inside an iframe, the snippet is an error that says a click or key press was sent, and the run exits with code `1`.
|
|
536
545
|
|
|
537
546
|
## Roadmap
|
|
538
547
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
|
+
"@projectwallace/css-analyzer": "9.9.5",
|
|
24
25
|
"vitest": "^5.0.5",
|
|
25
26
|
"web-vitals": "^6.2.3"
|
|
26
27
|
},
|
|
@@ -169,9 +169,14 @@
|
|
|
169
169
|
observer.observe({
|
|
170
170
|
type: "event",
|
|
171
171
|
buffered: true,
|
|
172
|
-
durationThreshold: 16, //
|
|
172
|
+
durationThreshold: 16, // The lowest the browser accepts
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// With no frame after it, an interaction that paints nothing is measured to the end of its handlers,
|
|
176
|
+
// under the 16 ms of `event` entries. `first-input` has no threshold, so the first one is still read;
|
|
177
|
+
// web-vitals does the same.
|
|
178
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
179
|
+
|
|
175
180
|
// Log INP summary
|
|
176
181
|
const logINP = () => {
|
|
177
182
|
const rating = valueToRating(inpValue);
|
|
@@ -231,7 +236,7 @@
|
|
|
231
236
|
console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`);
|
|
232
237
|
|
|
233
238
|
// Visual bar
|
|
234
|
-
const total =
|
|
239
|
+
const total = phases.inputDelay + phases.processingTime + phases.presentationDelay;
|
|
235
240
|
const barWidth = 40;
|
|
236
241
|
const inputBar = "▓".repeat(Math.round((phases.inputDelay / total) * barWidth));
|
|
237
242
|
const processBar = "█".repeat(Math.round((phases.processingTime / total) * barWidth));
|
|
@@ -358,15 +363,26 @@
|
|
|
358
363
|
details,
|
|
359
364
|
};
|
|
360
365
|
}
|
|
366
|
+
const value = Math.round(inpValue);
|
|
361
367
|
return {
|
|
362
368
|
script: "INP",
|
|
363
369
|
status: "ok",
|
|
364
370
|
metric: "INP",
|
|
365
|
-
value
|
|
371
|
+
value,
|
|
366
372
|
unit: "ms",
|
|
367
373
|
rating,
|
|
368
374
|
thresholds: { good: 200, needsImprovement: 500 },
|
|
369
375
|
details,
|
|
376
|
+
// Under one frame there is no paint to measure to: the value is real, and says little about the page
|
|
377
|
+
...(inpValue < 16 && {
|
|
378
|
+
issues: [
|
|
379
|
+
{
|
|
380
|
+
code: "no-paint-measured",
|
|
381
|
+
severity: "info",
|
|
382
|
+
message: `Worst interaction: ${value} ms, under the 16 ms of a frame, so the page most likely painted nothing after it. If it should respond, check that the click or key press reaches an element that does.`,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
}),
|
|
370
386
|
};
|
|
371
387
|
};
|
|
372
388
|
|
|
@@ -492,7 +508,7 @@
|
|
|
492
508
|
});
|
|
493
509
|
|
|
494
510
|
console.log("%c⚡ INP Tracking Active", "font-weight: bold; font-size: 14px;");
|
|
495
|
-
console.log(" Interactions
|
|
511
|
+
console.log(" Interactions of 16ms or more are tracked, and the first one whatever it takes.");
|
|
496
512
|
console.log(
|
|
497
513
|
" Call %cgetINP()%c to see current INP value.",
|
|
498
514
|
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
@@ -64,7 +64,12 @@
|
|
|
64
64
|
}
|
|
65
65
|
// @end-shared uniqueSelector
|
|
66
66
|
|
|
67
|
+
// The element of an entry is null once its node has left the DOM, as with a loading spinner that
|
|
68
|
+
// an app removes when it renders. The entry keeps its time, size and URL.
|
|
69
|
+
const REMOVED = "(removed from DOM)";
|
|
70
|
+
|
|
67
71
|
const getElementInfo = (element, entry) => {
|
|
72
|
+
if (!element) return entry.url ? { type: "Image", url: entry.url } : { type: "Removed element" };
|
|
68
73
|
const tag = element.tagName.toLowerCase();
|
|
69
74
|
if (tag === "img") return { type: "Image", url: entry.url || element.src };
|
|
70
75
|
if (tag === "video") return { type: "Video poster", url: entry.url || element.poster };
|
|
@@ -72,6 +77,19 @@
|
|
|
72
77
|
return { type: tag === "h1" || tag === "p" ? "Text block" : tag };
|
|
73
78
|
};
|
|
74
79
|
|
|
80
|
+
// One candidate per element, or per entry when its element was removed
|
|
81
|
+
const candidateKey = (entry) => entry.element ?? `${entry.startTime}|${entry.size}`;
|
|
82
|
+
|
|
83
|
+
// A data: URL keeps its media type, and another URL its file name
|
|
84
|
+
const shortUrl = (url) => {
|
|
85
|
+
if (url.startsWith("data:")) return `${url.split(",")[0]},…`;
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(url);
|
|
88
|
+
const file = u.pathname.split("/").pop()?.split("?")[0];
|
|
89
|
+
return u.hostname !== location.hostname ? `${u.hostname}/…/${file}` : file || url;
|
|
90
|
+
} catch { return url; }
|
|
91
|
+
};
|
|
92
|
+
|
|
75
93
|
const candidates = [];
|
|
76
94
|
|
|
77
95
|
const logTrail = () => {
|
|
@@ -89,12 +107,13 @@
|
|
|
89
107
|
// Current LCP element attribution
|
|
90
108
|
console.log("");
|
|
91
109
|
console.log("%cLCP Element:", "font-weight: bold;");
|
|
92
|
-
console.log(` Element: ${current.selector}`, current.element);
|
|
110
|
+
if (current.element) console.log(` Element: ${current.selector}`, current.element);
|
|
111
|
+
else console.log(` Element: ${REMOVED}${current.entry.id ? ` #${current.entry.id}` : ""}`);
|
|
93
112
|
|
|
94
113
|
const { type, url } = getElementInfo(current.element, current.entry);
|
|
95
114
|
console.log(` Type: ${type}`);
|
|
96
|
-
if (url) console.log(` URL: ${url}`);
|
|
97
|
-
if (current.element
|
|
115
|
+
if (url) console.log(` URL: ${shortUrl(url)}`);
|
|
116
|
+
if (current.element?.naturalWidth) {
|
|
98
117
|
console.log(
|
|
99
118
|
` Dimensions: ${current.element.naturalWidth}×${current.element.naturalHeight}`
|
|
100
119
|
);
|
|
@@ -111,14 +130,14 @@
|
|
|
111
130
|
console.log(
|
|
112
131
|
`%c ● ${index}. ${selector}`,
|
|
113
132
|
`color: ${color}; font-weight: ${isCurrent ? "bold" : "normal"};`,
|
|
114
|
-
`| ${(time / 1000).toFixed(2)}s — ${name}${isCurrent ? " ← LCP" : ""}`,
|
|
115
|
-
element
|
|
133
|
+
`| ${(time / 1000).toFixed(2)}s${element ? ` — ${name}` : ""}${isCurrent ? " ← LCP" : ""}`,
|
|
134
|
+
...(element ? [element] : [])
|
|
116
135
|
);
|
|
117
136
|
});
|
|
118
137
|
|
|
119
138
|
console.log("");
|
|
120
139
|
console.log(
|
|
121
|
-
"%c✓ Each candidate highlighted with a colored dashed outline",
|
|
140
|
+
"%c✓ Each candidate in the DOM highlighted with a colored dashed outline",
|
|
122
141
|
"color: #22c55e;"
|
|
123
142
|
);
|
|
124
143
|
console.groupEnd();
|
|
@@ -126,28 +145,30 @@
|
|
|
126
145
|
|
|
127
146
|
const observer = new PerformanceObserver((list) => {
|
|
128
147
|
const activationStart = getActivationStart();
|
|
129
|
-
const seen = new Set(candidates.map((c) => c.
|
|
148
|
+
const seen = new Set(candidates.map((c) => candidateKey(c.entry)));
|
|
130
149
|
|
|
131
150
|
for (const entry of list.getEntries()) {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
151
|
+
const key = candidateKey(entry);
|
|
152
|
+
if (seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
134
154
|
|
|
155
|
+
const { element } = entry;
|
|
135
156
|
const { color, name } = PALETTE[candidates.length % PALETTE.length];
|
|
136
157
|
|
|
137
|
-
element
|
|
138
|
-
|
|
158
|
+
if (element) {
|
|
159
|
+
element.style.outline = `3px dashed ${color}`;
|
|
160
|
+
element.style.outlineOffset = "2px";
|
|
161
|
+
}
|
|
139
162
|
|
|
140
163
|
candidates.push({
|
|
141
164
|
index: candidates.length + 1,
|
|
142
165
|
element,
|
|
143
|
-
selector: uniqueSelector(element),
|
|
144
|
-
color,
|
|
166
|
+
selector: element ? uniqueSelector(element) : REMOVED,
|
|
167
|
+
color: element ? color : "#9CA3AF",
|
|
145
168
|
name,
|
|
146
169
|
time: Math.max(0, entry.startTime - activationStart),
|
|
147
170
|
entry,
|
|
148
171
|
});
|
|
149
|
-
|
|
150
|
-
seen.add(element);
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
logTrail();
|
|
@@ -171,37 +192,28 @@
|
|
|
171
192
|
return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
|
|
172
193
|
}
|
|
173
194
|
const trailActivationStart = getActivationStart();
|
|
174
|
-
const
|
|
175
|
-
const syncCandidates = [];
|
|
195
|
+
const byKey = new Map();
|
|
176
196
|
for (const entry of trailEntries) {
|
|
197
|
+
const key = candidateKey(entry);
|
|
198
|
+
if (byKey.has(key)) continue;
|
|
177
199
|
const el = entry.element;
|
|
178
|
-
if (!el || seenEls.has(el)) continue;
|
|
179
|
-
seenEls.add(el);
|
|
180
|
-
const selector = uniqueSelector(el);
|
|
181
|
-
const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
|
|
182
200
|
const { type, url } = getElementInfo(el, entry);
|
|
183
|
-
|
|
184
|
-
index:
|
|
185
|
-
selector,
|
|
186
|
-
|
|
201
|
+
byKey.set(key, {
|
|
202
|
+
index: byKey.size + 1,
|
|
203
|
+
selector: el ? uniqueSelector(el) : null,
|
|
204
|
+
...(el ? {} : { removed: true }),
|
|
205
|
+
...(!el && entry.id ? { id: entry.id } : {}),
|
|
206
|
+
time: Math.round(Math.max(0, entry.startTime - trailActivationStart)),
|
|
187
207
|
elementType: type,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const u = new URL(url);
|
|
192
|
-
return u.hostname !== location.hostname
|
|
193
|
-
? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}`
|
|
194
|
-
: u.pathname.split("/").pop()?.split("?")[0] || url;
|
|
195
|
-
} catch { return url; }
|
|
196
|
-
})(),
|
|
197
|
-
} : {}),
|
|
208
|
+
sizePixels: entry.size,
|
|
209
|
+
...(url ? { url: shortUrl(url) } : {}),
|
|
198
210
|
});
|
|
199
211
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const lastCandidate =
|
|
204
|
-
const trailValue =
|
|
212
|
+
const syncCandidates = [...byKey.values()];
|
|
213
|
+
// The LCP is the last entry, whether or not its element is still in the DOM
|
|
214
|
+
const lastEntry = trailEntries.at(-1);
|
|
215
|
+
const lastCandidate = byKey.get(candidateKey(lastEntry));
|
|
216
|
+
const trailValue = Math.round(Math.max(0, lastEntry.startTime - trailActivationStart));
|
|
205
217
|
const trailRating = valueToRating(trailValue);
|
|
206
218
|
return {
|
|
207
219
|
script: "LCP-Trail",
|
|
@@ -92,6 +92,9 @@
|
|
|
92
92
|
const tracker = createInteractionTracker();
|
|
93
93
|
const observer = new PerformanceObserver((list) => tracker.add(list.getEntries()));
|
|
94
94
|
observer.observe({ type: "event", durationThreshold: 0, buffered: true });
|
|
95
|
+
// The browser gives no `event` entries under 16 ms, whatever the threshold, and an interaction that
|
|
96
|
+
// paints nothing can take less. `first-input` has no threshold, so the first one is still read.
|
|
97
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
95
98
|
|
|
96
99
|
// Interactions grouped by event type: "click", "keydown", "pointerdown", etc.
|
|
97
100
|
const groupByEventType = () => {
|
|
@@ -173,6 +173,10 @@
|
|
|
173
173
|
buffered: true,
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
// The browser gives no `event` entries under 16 ms, whatever the threshold, and an interaction that
|
|
177
|
+
// paints nothing can take less. `first-input` has no threshold, so the first one is still read.
|
|
178
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
179
|
+
|
|
176
180
|
// Summary function
|
|
177
181
|
window.getInteractionSummary = () => {
|
|
178
182
|
const allInteractions = tracker.list().map((i) => ({
|