webperf-snippets 0.2.0 → 0.4.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.
Files changed (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
package/README.md CHANGED
@@ -5,11 +5,11 @@ Run curated [WebPerf Snippets](https://webperf-snippets.nucliweb.net) headlessly
5
5
  <img width="1820" height="1442" alt="webperf-snippets-CLI" src="https://github.com/user-attachments/assets/af7e6b02-8877-407e-87a4-db063468b5fb" />
6
6
 
7
7
 
8
- > **Status:** v0.2. Core Web Vitals, loading audit, and structural checks. See [Roadmap](#roadmap) for what's next.
8
+ > **Status:** v0.4. Core Web Vitals, loading, interaction and structural audit workflows, and every snippet of the collection by name. See the [Roadmap](#roadmap).
9
9
 
10
10
  ## Why
11
11
 
12
- Lighthouse gives you a score. The DevTools snippets give you the *diagnosis* — TTFB / Resource Load Delay / Element Render Delay sub-parts, LoAF script attribution, render-blocking resources, etc. This CLI runs the same curated snippets in a headless browser so you can:
12
+ Lighthouse gives you a score. The DevTools snippets give you the *diagnosis*, such as TTFB / Resource Load Delay / Element Render Delay sub-parts, LoAF script attribution, and render-blocking resources. This CLI runs the same curated snippets in a headless browser so you can:
13
13
 
14
14
  - Diagnose LCP regressions in CI without copy-pasting into DevTools.
15
15
  - Gate pull requests on real performance budgets.
@@ -38,13 +38,13 @@ Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP >
38
38
  npx webperf-snippets https://web.dev
39
39
  ```
40
40
 
41
- Loading audit (TTFB, FCP, render-blocking, scripts, fonts):
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.nucliweb.net/CLI)):
42
42
 
43
43
  ```bash
44
44
  npx webperf-snippets https://web.dev --workflow loading
45
45
  ```
46
46
 
47
- Structural checks for CI (render-blocking, fonts, priority hints, resource hints):
47
+ 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.nucliweb.net/CLI)):
48
48
 
49
49
  ```bash
50
50
  npx webperf-snippets https://web.dev --workflow audit
@@ -72,8 +72,76 @@ Synthetic INP measurement with an interaction script:
72
72
 
73
73
  ```bash
74
74
  npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
75
+ npx webperf-snippets https://web.dev --snippet Interaction/Interactions --interact-script interactions.json
75
76
  ```
76
77
 
78
+ 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.
79
+
80
+ 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:
81
+
82
+ ```bash
83
+ npx webperf-snippets https://web.dev --workflow interaction
84
+ ```
85
+
86
+ Pages behind a login, with a Playwright storage state (cookies and localStorage):
87
+
88
+ ```bash
89
+ # Step 1: sign in once and save the session
90
+ npx playwright codegen --save-storage=auth.json https://example.com/login
91
+
92
+ # Step 2: measure an authenticated page
93
+ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
94
+ ```
95
+
96
+ 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.
97
+
98
+ Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
99
+
100
+ ```bash
101
+ CRUX_API_KEY=your-key npx webperf-snippets https://web.dev
102
+ ```
103
+
104
+ ```
105
+ Field data (CrUX p75, mobile)
106
+ 🟡 LCP 2.80s needs-improvement (synthetic: 2.10s)
107
+ 🟢 CLS 0.0800 good (synthetic: 0.0500)
108
+ 🟡 INP 220ms needs-improvement (synthetic: n/a)
109
+ ```
110
+
111
+ Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/api) and pass it with `--crux-key` or the `CRUX_API_KEY` environment variable; prefer the variable, because a flag ends up in shell history and CI logs. The section appears in the human and markdown output and under `crux` in the `--json` output.
112
+
113
+ - The population follows `--viewport`: `desktop` uses desktop data, `mobile` and `tablet` use phone data. The heading says which one.
114
+ - When the page itself has no data, the CLI looks the origin up instead and labels the section `origin-level`, so it is not mistaken for page-level data. If the origin has none either, the output says `Field data: not available`.
115
+ - A CrUX error (quota, network, no answer within 10 seconds) only skips the section. It never changes the exit code, and the key is never printed.
116
+ - INP shows `n/a` as the synthetic value unless you run an interaction script.
117
+
118
+ Send the results to an endpoint after the run with `--report-to`:
119
+
120
+ ```bash
121
+ npx webperf-snippets https://example.com --workflow audit \
122
+ --report-to https://reports.example.com/api/report \
123
+ --api-key $REPORT_API_KEY
124
+ ```
125
+
126
+ Without `--report-to` the CLI makes no external calls. With it, the results are POSTed as JSON once the snippets have finished:
127
+
128
+ ```json
129
+ {
130
+ "url": "https://example.com",
131
+ "workflow": "audit",
132
+ "timestamp": "2026-05-06T10:00:00.000Z",
133
+ "navMs": 1240,
134
+ "results": [],
135
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.4.0" }
136
+ }
137
+ ```
138
+
139
+ 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.
140
+
141
+ - The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
142
+ - `--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.
143
+ - 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.
144
+
77
145
  CI gating:
78
146
 
79
147
  ```bash
@@ -84,13 +152,17 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
84
152
 
85
153
  | Option | Description |
86
154
  | ---------------------------- | ---------------------------------------------------------------------- |
87
- | `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. Options: `core-web-vitals`, `loading`, `audit`. |
88
- | `--snippet <name>` | Run a single snippet by alias or `Category/Name` path. |
155
+ | `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. Options: `core-web-vitals`, `loading`, `audit`, `interaction`. |
156
+ | `--snippet <name>` | Run a single snippet by name, alias or `Category/Name` path. |
89
157
  | `--json` | Output JSON instead of formatted text. |
90
158
  | `--markdown` | Output GitHub-renderable markdown (for PR comments). |
91
159
  | `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
92
160
  | `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
93
- | `--interact-script <path>` | JSON file with interactions to run before evaluation (for INP). |
161
+ | `--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. |
162
+ | `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
163
+ | `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
164
+ | `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
165
+ | `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
94
166
  | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
95
167
  | `--budget-cls <score>` | Exit `1` if CLS exceeds this value. |
96
168
  | `--verbose` | Show all items, including passing checks. |
@@ -103,6 +175,7 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
103
175
  | ------------------ | ---------------------------------------------- |
104
176
  | `LCP` | CoreWebVitals/LCP |
105
177
  | `CLS` | CoreWebVitals/CLS |
178
+ | `INP` | CoreWebVitals/INP |
106
179
  | `LCP-Subparts` | CoreWebVitals/LCP-Subparts |
107
180
  | `fonts` | Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold |
108
181
  | `render-blocking` | Loading/Find-render-blocking-resources |
@@ -117,12 +190,16 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
117
190
  | `lazy-conflict` | Loading/Find-Images-With-Lazy-and-Fetchpriority |
118
191
  | `eager-below-fold` | Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport |
119
192
 
193
+ ### Follow-up steps
194
+
195
+ The results of a run can add steps on the same page load: LCP over 2.5 s adds `LCP-Subparts`, over 4 s `LCP-Trail`; an image or video LCP adds its entropy, image or video audit; CLS over 0.1 adds `Layout-Shift-Loading-and-Interaction`, over 0.25 also the lazy images, fonts, critical CSS and images without dimensions; TTFB over 600 ms adds `TTFB-Sub-Parts`; FCP over 1.8 s adds the render-blocking check. The full table is in the [CLI page](https://webperf-snippets.nucliweb.net/CLI). Each snippet runs once, and a follow-up informs: its `error` issues do not change the exit code.
196
+
120
197
  ### Exit codes
121
198
 
122
199
  | Code | Meaning |
123
200
  | ---- | --------------------------------------------- |
124
201
  | `0` | All checks passed. |
125
- | `1` | Budget violation, or a snippet errored. |
202
+ | `1` | Budget violation, a snippet errored, or a step of the workflow reported an `error` issue. |
126
203
  | `2` | Usage error (missing URL, unknown workflow). |
127
204
 
128
205
  ## CI example
@@ -136,9 +213,60 @@ GitHub Actions, fail the PR if LCP exceeds 2.5s:
136
213
  npx webperf-snippets https://staging.web.dev --budget-lcp 2500 --budget-cls 0.1
137
214
  ```
138
215
 
216
+ ### GitHub Action
217
+
218
+ The repository ships a composite action that installs the CLI and Chromium, runs a workflow and exposes the result as markdown, so any repository can gate on performance budgets without extra setup:
219
+
220
+ ```yaml
221
+ jobs:
222
+ webperf:
223
+ runs-on: ubuntu-latest
224
+ permissions:
225
+ pull-requests: write
226
+ steps:
227
+ - uses: nucliweb/webperf-snippets/.github/actions/webperf-snippets@main
228
+ id: webperf
229
+ with:
230
+ url: https://staging.example.com
231
+ budget-lcp: 2500
232
+ fail-on-budget: "false"
233
+
234
+ - uses: actions/github-script@v7
235
+ env:
236
+ BODY: ${{ steps.webperf.outputs.markdown }}
237
+ with:
238
+ script: |
239
+ await github.rest.issues.createComment({
240
+ ...context.repo,
241
+ issue_number: context.issue.number,
242
+ body: process.env.BODY,
243
+ });
244
+
245
+ - if: steps.webperf.outputs.exit-code != '0'
246
+ run: exit 1
247
+ ```
248
+
249
+ | Input | Default | Description |
250
+ | ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
251
+ | `url` | required | URL to measure. |
252
+ | `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading`, `audit` or `interaction`. |
253
+ | `budget-lcp` | `2500` | LCP budget in ms. An empty value disables the check. |
254
+ | `budget-cls` | `0.1` | CLS budget. An empty value disables the check. |
255
+ | `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
256
+ | `cli-version` | `latest` | Version of the `webperf-snippets` npm package to run. |
257
+
258
+ | Output | Description |
259
+ | ----------- | ------------------------------------------------------------------------------------ |
260
+ | `markdown` | The results as markdown. Also written to the job summary. |
261
+ | `exit-code` | Exit code of the CLI: `0` passed, `1` budget exceeded or a snippet errored, `2` usage error. |
262
+
263
+ The markdown includes text taken from the measured page, so pass it to other steps through an environment variable (`env:` and `process.env`) as above, never by interpolating `${{ steps.webperf.outputs.markdown }}` inside a script. With `fail-on-budget: "false"` the action step always succeeds, which lets later steps post the comment before the job fails. Without `permissions: pull-requests: write` the comment step cannot post on a pull request.
264
+
265
+ LCP, CLS and INP vary by 15 to 30% on shared runners; prefer the deterministic `audit` workflow for a gate, or generous budgets.
266
+
139
267
  ## Publishing
140
268
 
141
- The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional — it only happens when a `cli-v*` tag is pushed.
269
+ The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed.
142
270
 
143
271
  ### Release steps
144
272
 
@@ -153,7 +281,7 @@ The CLI package is published to npm via a tag-based workflow. Publishing is expl
153
281
 
154
282
  ### Why tag-based and not path-based
155
283
 
156
- An alternative is to publish automatically on every push to `main` that touches `cli/`, using a version check to skip republishes. Tag-based publishing was chosen instead because it keeps releases deliberate — a passing CI on `main` does not mean the package is ready to ship, and a tag communicates that intent explicitly.
284
+ An alternative is to publish automatically on every push to `main` that touches `cli/`, using a version check to skip republishes. Tag-based publishing was chosen instead because it keeps releases deliberate; a passing CI on `main` does not mean the package is ready to ship, and a tag communicates that intent explicitly.
157
285
 
158
286
  ### Access control
159
287
 
@@ -167,13 +295,13 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
167
295
 
168
296
  - **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
169
297
  - **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
170
- - **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.
298
+ - **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.
171
299
 
172
300
  ## Roadmap
173
301
 
174
302
  - ~~v0.2: Loading workflow (TTFB, FCP, render-blocking, scripts, fonts), shared page session, synthetic interactions for INP, markdown reporter for PR comments.~~ ✓ Released
175
- - v0.3: GitHub Action wrapper.
176
- - v0.4: Auth flows (login + measure logged-in pages), CrUX field-data enrichment.
303
+ - ~~v0.3: GitHub Action wrapper, auth flows (login + measure logged-in pages), CrUX field-data enrichment, `--report-to`.~~ ✓ Released
304
+ - ~~v0.4: Every snippet by name, a 22-step audit, an 11-step loading workflow, the interaction workflow and decision-tree follow-ups for Core Web Vitals.~~ ✓ Released
177
305
 
178
306
  ## How it works
179
307
 
@@ -186,4 +314,4 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
186
314
 
187
315
  ## License
188
316
 
189
- MIT — see [LICENSE](../LICENSE).
317
+ MIT, see [LICENSE](../LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
5
5
  "type": "module",
6
6
  "bin": "./src/bin.js",
@@ -2,7 +2,31 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
6
+ console.warn("⚠️ layout-shift entries are not supported in this browser.");
7
+ return { script: "CLS", status: "unsupported", error: "layout-shift entries not supported in this browser" };
8
+ }
9
+ // CLS is the largest session window: shifts less than 1s apart, in a window of at most 5s.
5
10
  let cls = 0;
11
+ let sessionValue = 0;
12
+ let sessionFirst = null;
13
+ let sessionLast = null;
14
+
15
+ const addShift = (entry) => {
16
+ if (entry.hadRecentInput) return;
17
+ if (
18
+ sessionLast &&
19
+ entry.startTime - sessionLast.startTime < 1000 &&
20
+ entry.startTime - sessionFirst.startTime < 5000
21
+ ) {
22
+ sessionValue += entry.value;
23
+ } else {
24
+ sessionValue = entry.value;
25
+ sessionFirst = entry;
26
+ }
27
+ sessionLast = entry;
28
+ cls = Math.max(cls, sessionValue);
29
+ };
6
30
 
7
31
  const valueToRating = (score) =>
8
32
  score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
@@ -23,11 +47,7 @@
23
47
  };
24
48
 
25
49
  const observer = new PerformanceObserver((list) => {
26
- for (const entry of list.getEntries()) {
27
- if (!entry.hadRecentInput) {
28
- cls += entry.value;
29
- }
30
- }
50
+ for (const entry of list.getEntries()) addShift(entry);
31
51
  logCLS();
32
52
  });
33
53
 
@@ -63,16 +83,10 @@
63
83
  ""
64
84
  );
65
85
 
66
- // Return for agent — collect via buffered observer (getEntriesByType does not
67
- // expose layout-shift entries in Chrome without an active observer).
68
- const clsSync = await new Promise((resolve) => {
69
- let sum = 0;
70
- const obs = new PerformanceObserver((list) => {
71
- for (const e of list.getEntries()) if (!e.hadRecentInput) sum += e.value;
72
- });
73
- obs.observe({ type: "layout-shift", buffered: true });
74
- setTimeout(() => { obs.disconnect(); resolve(sum); }, 100);
75
- });
86
+ // Return for agent. Chrome only exposes layout-shift entries through a PerformanceObserver,
87
+ // so wait for the buffered observer above to deliver them.
88
+ await new Promise((resolve) => setTimeout(resolve, 100));
89
+ const clsSync = cls;
76
90
  const clsRating = valueToRating(clsSync);
77
91
  return {
78
92
  script: "CLS",
@@ -2,6 +2,10 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
6
+ console.warn("⚠️ event entries are not supported in this browser.");
7
+ return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
8
+ }
5
9
  const interactions = [];
6
10
  let inpValue = 0;
7
11
  let inpEntry = null;
@@ -24,10 +28,10 @@
24
28
  // Sort by duration
25
29
  const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
26
30
 
27
- // Get 98th percentile (or worst if < 50 interactions)
28
- const index = interactions.length < 50
29
- ? 0
30
- : Math.floor(interactions.length * 0.02);
31
+ // Skip one interaction for every 50. Use the browser's total interaction count when
32
+ // available: `interactions` only holds those above the duration threshold.
33
+ const totalInteractions = performance.interactionCount || interactions.length;
34
+ const index = Math.min(sorted.length - 1, Math.floor(totalInteractions / 50));
31
35
 
32
36
  return {
33
37
  value: sorted[index].duration,
@@ -50,7 +54,7 @@
50
54
  return `${entry.name} → ${selector}`;
51
55
  };
52
56
 
53
- // Get phase breakdown (requires LoAF support)
57
+ // Get phase breakdown from the Event Timing entry
54
58
  const getPhaseBreakdown = (entry) => {
55
59
  const phases = {
56
60
  inputDelay: 0,
@@ -61,7 +65,10 @@
61
65
  if (entry.processingStart && entry.processingEnd) {
62
66
  phases.inputDelay = entry.processingStart - entry.startTime;
63
67
  phases.processingTime = entry.processingEnd - entry.processingStart;
64
- phases.presentationDelay = entry.duration - phases.inputDelay - phases.processingTime;
68
+ phases.presentationDelay = Math.max(
69
+ 0,
70
+ entry.duration - phases.inputDelay - phases.processingTime
71
+ );
65
72
  }
66
73
 
67
74
  return phases;
@@ -130,7 +137,7 @@
130
137
 
131
138
  if (inpEntry) {
132
139
  console.log("");
133
- console.log(`%c🎯 Worst Interaction (INP):`, "font-weight: bold; color: ${color};");
140
+ console.log(`%c🎯 Worst Interaction (INP):`, `font-weight: bold; color: ${color};`);
134
141
  console.log(` Event: ${inpEntry.formattedName}`);
135
142
  console.log(` Duration: ${formatMs(inpEntry.duration)}`);
136
143
 
@@ -1,14 +1,20 @@
1
1
  // LCP Image Entropy Check
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- (() => {
5
- const formatBytes = (bytes) => {
6
- if (!bytes) return "-";
7
- const k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
11
- };
4
+ (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
6
+ console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
7
+ return { script: "LCP-Image-Entropy", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
8
+ }
9
+ // @shared formatBytes
10
+ function formatBytes(bytes) {
11
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
12
+ if (bytes === 0) return "0 B";
13
+ const units = ["B", "KB", "MB", "GB"];
14
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
15
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
16
+ }
17
+ // @end-shared formatBytes
12
18
 
13
19
  const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
14
20
 
@@ -142,8 +148,15 @@
142
148
  console.groupEnd();
143
149
  }, 100);
144
150
 
145
- // Synchronous return for agent (buffered entries + DOM)
146
- const lcpEntriesSync = performance.getEntriesByType("largest-contentful-paint");
151
+ // Return for agent (buffered entries + DOM)
152
+ // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
153
+ // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
154
+ const lcpEntriesSync = await new Promise((resolve) => {
155
+ const entries = [];
156
+ const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
157
+ obs.observe({ type: "largest-contentful-paint", buffered: true });
158
+ setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
159
+ });
147
160
  const lcpEntrySync = lcpEntriesSync.at(-1);
148
161
  const lcpElementSync = lcpEntrySync?.element ?? null;
149
162
  const lcpUrlSync = lcpEntrySync?.url ?? null;
@@ -182,6 +195,11 @@
182
195
  if (lcpImageSync?.isLowEntropy) {
183
196
  issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
184
197
  }
198
+ // The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
199
+ const MAX_ITEMS = 50;
200
+ const rankedImages = [...imagesSync].sort(
201
+ (a, b) => Number(b.isLCP) - Number(a.isLCP) || Number(b.isLowEntropy) - Number(a.isLowEntropy) || a.bpp - b.bpp
202
+ );
185
203
  return {
186
204
  script: "LCP-Image-Entropy",
187
205
  status: "ok",
@@ -192,7 +210,7 @@
192
210
  lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
193
211
  lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
194
212
  },
195
- items: imagesSync,
213
+ items: rankedImages.slice(0, MAX_ITEMS),
196
214
  issues: issuesSync,
197
215
  };
198
216
  })();
@@ -2,6 +2,10 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
6
+ console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
7
+ return { script: "LCP-Subparts", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
8
+ }
5
9
  const formatMs = (ms) => `${Math.round(ms)}ms`;
6
10
  const formatPercent = (value, total) => `${Math.round((value / total) * 100)}%`;
7
11
 
@@ -2,7 +2,11 @@
2
2
  // Tracks all LCP candidate elements during page load
3
3
  // https://webperf-snippets.nucliweb.net
4
4
 
5
- (() => {
5
+ (async () => {
6
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
7
+ console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
8
+ return { script: "LCP-Trail", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
9
+ }
6
10
  const PALETTE = [
7
11
  { color: "#EF4444", name: "Red" },
8
12
  { color: "#F97316", name: "Orange" },
@@ -129,8 +133,15 @@
129
133
  console.log("%c⏱️ LCP Trail Active", "font-weight: bold; font-size: 14px;");
130
134
  console.log(" Highlights all LCP candidate elements with distinct colors.");
131
135
 
132
- // Synchronous return for agent (buffered entries)
133
- const trailEntries = performance.getEntriesByType("largest-contentful-paint");
136
+ // Return for agent (buffered entries)
137
+ // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
138
+ // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
139
+ const trailEntries = await new Promise((resolve) => {
140
+ const entries = [];
141
+ const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
142
+ obs.observe({ type: "largest-contentful-paint", buffered: true });
143
+ setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
144
+ });
134
145
  if (trailEntries.length === 0) {
135
146
  return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
136
147
  }
@@ -1,8 +1,26 @@
1
1
  // LCP Video Candidate
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- (() => {
5
- const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
4
+ (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
6
+ console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
7
+ return { script: "LCP-Video-Candidate", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
8
+ }
9
+ // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver, so
10
+ // performance.getEntriesByType() returns [] here. Collect via a buffered observer.
11
+ const collectBuffered = (type, timeoutMs = 100) =>
12
+ new Promise((resolve) => {
13
+ const entries = [];
14
+ try {
15
+ const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
16
+ obs.observe({ type, buffered: true });
17
+ setTimeout(() => { obs.disconnect(); resolve(entries); }, timeoutMs);
18
+ } catch {
19
+ resolve(entries);
20
+ }
21
+ });
22
+
23
+ const lcpEntries = await collectBuffered("largest-contentful-paint");
6
24
 
7
25
  if (lcpEntries.length === 0) {
8
26
  console.warn(
@@ -2,6 +2,10 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
6
+ console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
7
+ return { script: "LCP", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
8
+ }
5
9
  const valueToRating = (ms) =>
6
10
  ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
7
11
 
@@ -97,8 +101,6 @@
97
101
  return { script: "LCP", status: "error", error: "No LCP entries buffered" };
98
102
  }
99
103
 
100
- printLCP(lastLcpEntry);
101
-
102
104
  const lcpActivationStart = getActivationStart();
103
105
  const lcpValue = Math.round(Math.max(0, lastLcpEntry.startTime - lcpActivationStart));
104
106
  const lcpRating = valueToRating(lcpValue);
@@ -1,7 +1,7 @@
1
1
  // Fetch & XHR Timeline — Run in console after page load
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- (() => {
4
+ (async () => {
5
5
  const calls = window.__perfCalls;
6
6
 
7
7
  if (!calls) {
@@ -11,7 +11,14 @@
11
11
  return;
12
12
  }
13
13
 
14
- const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
14
+ // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
15
+ // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
16
+ const lcpEntries = await new Promise((resolve) => {
17
+ const entries = [];
18
+ const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
19
+ obs.observe({ type: "largest-contentful-paint", buffered: true });
20
+ setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
21
+ });
15
22
  const lcpTime = lcpEntries.length
16
23
  ? Math.round(lcpEntries[lcpEntries.length - 1].startTime)
17
24
  : window.__lcpTime ?? null;