webperf-snippets 0.4.0 → 0.5.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 (65) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/snippets/CoreWebVitals/CLS.js +1 -1
  4. package/snippets/CoreWebVitals/INP.js +1 -1
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +15 -1
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +47 -18
  7. package/snippets/CoreWebVitals/LCP-Trail.js +35 -10
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +48 -24
  9. package/snippets/CoreWebVitals/LCP.js +37 -15
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +1 -1
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +1 -1
  12. package/snippets/Interaction/DOM-Size-and-Depth.js +1 -1
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +1 -1
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +1 -1
  15. package/snippets/Interaction/Interactions.js +1 -1
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +1 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +1 -1
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +1 -1
  19. package/snippets/Interaction/Long-Animation-Frames.js +1 -1
  20. package/snippets/Interaction/LongTask.js +1 -1
  21. package/snippets/Interaction/Scroll-Performance.js +1 -1
  22. package/snippets/Loading/Back-Forward-Cache.js +19 -2
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +121 -456
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +41 -20
  25. package/snippets/Loading/Client-Side-Redirect-Detection.js +11 -3
  26. package/snippets/Loading/Compression-Audit.js +10 -2
  27. package/snippets/Loading/Critical-CSS-Detection.js +11 -3
  28. package/snippets/Loading/Event-Processing-Time.js +1 -1
  29. package/snippets/Loading/FCP.js +12 -4
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +12 -4
  31. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  32. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +10 -2
  33. package/snippets/Loading/Find-render-blocking-resources.js +11 -3
  34. package/snippets/Loading/First-And-Third-Party-Script-Info.js +10 -2
  35. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +10 -2
  36. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +1 -1
  37. package/snippets/Loading/Inline-CSS-Info-and-Size.js +1 -1
  38. package/snippets/Loading/Inline-Script-Info-and-Size.js +1 -1
  39. package/snippets/Loading/JS-Execution-Time-Breakdown.js +10 -2
  40. package/snippets/Loading/Prefetch-Resource-Validation.js +19 -5
  41. package/snippets/Loading/Priority-Hints-Audit.js +11 -3
  42. package/snippets/Loading/Resource-Hints-Validation.js +10 -2
  43. package/snippets/Loading/Resource-Hints.js +11 -3
  44. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +1 -1
  45. package/snippets/Loading/Script-Loading.js +10 -2
  46. package/snippets/Loading/Server-Timing-Early-Hints.js +10 -2
  47. package/snippets/Loading/Service-Worker-Analysis.js +10 -2
  48. package/snippets/Loading/Speculation-Rules-Inspector.js +1 -1
  49. package/snippets/Loading/TTFB-Resources.js +11 -3
  50. package/snippets/Loading/TTFB-Sub-Parts.js +27 -1
  51. package/snippets/Loading/TTFB.js +1 -1
  52. package/snippets/Loading/Third-Party-Impact-by-Domain.js +10 -2
  53. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +10 -2
  54. package/snippets/Loading/Webfont-Usage-Analyzer.js +105 -30
  55. package/snippets/Media/Image-Element-Audit.js +19 -2
  56. package/snippets/Media/Oversized-Images.js +28 -3
  57. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +48 -9
  58. package/snippets/Media/Video-Element-Audit.js +16 -10
  59. package/snippets/README.md +1 -1
  60. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +1 -1
  61. package/snippets/SCHEMA.md +33 -14
  62. package/snippets/_shared/helpers.js +59 -0
  63. package/src/interactions.js +27 -0
  64. package/src/runner.js +5 -3
  65. package/src/tracking.js +4 -4
@@ -1,7 +1,32 @@
1
1
  // SVG Embedded Bitmap Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (async () => {
5
+ // @shared ownRequestUrl
6
+ // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
7
+ // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
8
+ // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
9
+ // which is how isOwnRequest leaves these requests out of what the page loaded.
10
+ function ownRequestUrl(url) {
11
+ try {
12
+ const parsed = new URL(url, location.href);
13
+ if (!/^https?:$/.test(parsed.protocol)) return url;
14
+ parsed.hash = "webperf-snippets";
15
+ return parsed.href;
16
+ } catch {
17
+ return url;
18
+ }
19
+ }
20
+ // @end-shared ownRequestUrl
21
+
22
+ // @shared isOwnRequest
23
+ // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
24
+ // did not make it, so no snippet counts it.
25
+ function isOwnRequest(entry) {
26
+ return entry.name.endsWith("#webperf-snippets");
27
+ }
28
+ // @end-shared isOwnRequest
29
+
5
30
  function formatSize(bytes) {
6
31
  if (!bytes || bytes === 0) return "—";
7
32
  if (bytes < 1024) return `${bytes} B`;
@@ -55,7 +80,7 @@
55
80
 
56
81
  // ── External SVG files (Performance API) ──────────────────────────────
57
82
  const svgEntries = performance
58
- .getEntriesByType("resource")
83
+ .getEntriesByType("resource").filter((e) => !isOwnRequest(e))
59
84
  .filter((e) => e.name.split("?")[0].toLowerCase().endsWith(".svg"));
60
85
 
61
86
  const externalResults = await Promise.all(
@@ -64,7 +89,7 @@
64
89
  let bitmaps = [];
65
90
 
66
91
  try {
67
- const res = await fetch(entry.name, { cache: "force-cache" });
92
+ const res = await fetch(ownRequestUrl(entry.name), { cache: "force-cache" });
68
93
  const ce = res.headers.get("content-encoding");
69
94
  if (ce) compression = ce; // gzip | br | zstd | deflate
70
95
  bitmaps = findEmbeddedBitmaps(await res.text());
@@ -89,6 +114,8 @@
89
114
  const inlineSvgTotal = inlineSvgs.length;
90
115
  const svgsWithUse = inlineSvgs.filter((svg) => svg.querySelector("use")).length;
91
116
  const standaloneInlineSvgs = inlineSvgTotal - svgsWithUse;
117
+ // Five or more standalone inline SVGs are worth moving into a <symbol> sprite
118
+ const spriteOpportunity = standaloneInlineSvgs >= 5;
92
119
 
93
120
  const inlineResults = Array.from(document.querySelectorAll("svg"))
94
121
  .map((svg, i) => {
@@ -189,7 +216,7 @@
189
216
  );
190
217
  }
191
218
 
192
- if (standaloneInlineSvgs >= 5) {
219
+ if (spriteOpportunity) {
193
220
  console.log("");
194
221
  console.group("%c💡 SVG Sprite Opportunity", "color: #3b82f6; font-weight: bold;");
195
222
  console.log("");
@@ -213,6 +240,21 @@
213
240
 
214
241
  console.groupEnd();
215
242
 
243
+ const issues = [];
244
+ if (withBitmaps.length > 0) {
245
+ issues.push({ severity: "warning", message: `${withBitmaps.length} SVG file(s) contain embedded bitmaps` });
246
+ }
247
+ if (spriteOpportunity) {
248
+ const withUse =
249
+ svgsWithUse > 0
250
+ ? ` (${svgsWithUse} inline SVG${svgsWithUse === 1 ? " already uses" : "s already use"} <use>)`
251
+ : "";
252
+ issues.push({
253
+ severity: "info",
254
+ message: `${standaloneInlineSvgs} standalone inline SVGs could share one <symbol> sprite referenced with <use>${withUse}`,
255
+ });
256
+ }
257
+
216
258
  return {
217
259
  script: "SVG-Embedded-Bitmap-Analysis",
218
260
  status: "ok",
@@ -222,8 +264,7 @@
222
264
  inlineSvgTotal,
223
265
  svgsWithUse,
224
266
  standaloneInlineSvgs,
225
- // Five or more standalone inline SVGs are worth moving into a <symbol> sprite
226
- spriteOpportunity: standaloneInlineSvgs >= 5,
267
+ spriteOpportunity,
227
268
  },
228
269
  items: withBitmaps.map((r) => ({
229
270
  url: r.url || r.name,
@@ -233,8 +274,6 @@
233
274
  bitmapTypes: r.bitmaps.map((b) => b.format).join(", "),
234
275
  sizeBytes: r.transferSize,
235
276
  })),
236
- issues: withBitmaps.length > 0
237
- ? [{ severity: "warning", message: `${withBitmaps.length} SVG file(s) contain embedded bitmaps` }]
238
- : [],
277
+ issues,
239
278
  };
240
279
  })();
@@ -1,5 +1,5 @@
1
1
  // Video Element Audit
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  function isInViewport(el) {
@@ -54,8 +54,14 @@
54
54
  const videos = Array.from(document.querySelectorAll("video"));
55
55
 
56
56
  if (videos.length === 0) {
57
- console.log("No <video> elements found on this page.");
58
- return { script: "Video-Element-Audit", status: "ok", count: 0, items: [], issues: [] };
57
+ console.log("No <video> elements on this page, nothing to audit.");
58
+ return {
59
+ script: "Video-Element-Audit",
60
+ status: "ok",
61
+ count: 0,
62
+ items: [],
63
+ issues: [{ severity: "info", message: "No <video> elements on this page, so there was nothing to audit." }],
64
+ };
59
65
  }
60
66
 
61
67
  const audited = videos.map((video) => {
@@ -79,35 +85,35 @@
79
85
  if (preload === "auto" && !autoplay) {
80
86
  issues.push({
81
87
  s: "error",
82
- msg: 'preload="auto" loads the full video eagerly — use preload="none" or preload="metadata"',
88
+ msg: 'preload="auto" loads the full video eagerly: use preload="none" or preload="metadata"',
83
89
  });
84
90
  }
85
91
 
86
92
  if (!autoplay && !inViewport && preload !== "none") {
87
93
  issues.push({
88
94
  s: "warning",
89
- msg: 'Off-viewport video without preload="none" — set preload="none" to avoid unnecessary loading',
95
+ msg: 'Off-viewport video without preload="none": set preload="none" to avoid unnecessary loading',
90
96
  });
91
97
  }
92
98
 
93
99
  if (autoplay && !muted) {
94
100
  issues.push({
95
101
  s: "error",
96
- msg: "autoplay without muted — browsers block unmuted autoplay",
102
+ msg: "autoplay without muted: browsers block unmuted autoplay",
97
103
  });
98
104
  }
99
105
 
100
106
  if (autoplay && !playsinline) {
101
107
  issues.push({
102
108
  s: "warning",
103
- msg: "autoplay without playsinline — iOS Safari forces fullscreen mode",
109
+ msg: "autoplay without playsinline: iOS Safari forces fullscreen mode",
104
110
  });
105
111
  }
106
112
 
107
113
  if (!poster) {
108
114
  issues.push({
109
115
  s: "warning",
110
- msg: "Missing poster — no preview frame shown before playback starts",
116
+ msg: "Missing poster: no preview frame shown before playback starts",
111
117
  });
112
118
  }
113
119
 
@@ -121,14 +127,14 @@
121
127
  if (!isModernCodec(codec) && codec !== "no source") {
122
128
  issues.push({
123
129
  s: "info",
124
- msg: "No modern codec source detected — consider adding a WebM source (VP9 or AV1) for better compression",
130
+ msg: "No modern codec source detected: consider adding a WebM source (VP9 or AV1) for better compression",
125
131
  });
126
132
  }
127
133
 
128
134
  if (!controls && !autoplay) {
129
135
  issues.push({
130
136
  s: "info",
131
- msg: "No controls attribute and no autoplay — users may not be able to play the video",
137
+ msg: "No controls attribute and no autoplay, so users may not be able to play the video",
132
138
  });
133
139
  }
134
140
 
@@ -87,7 +87,7 @@ See `snippets/Loading/WORKFLOWS.md` for a complete example with:
87
87
  The `generate-skills.js` script:
88
88
 
89
89
  1. Reads all `.js` files from each category
90
- 2. Extracts metadata from corresponding MDX documentation in `/pages/`
90
+ 2. Extracts metadata from corresponding MDX documentation in `/content/`
91
91
  3. Injects `WORKFLOWS.md` content (if exists)
92
92
  4. Generates complete `SKILL.md` files with:
93
93
  - Snippet table
@@ -1,5 +1,5 @@
1
1
  // Network Bandwidth & Connection Quality Analysis
2
- // https://webperf-snippets.nucliweb.net
2
+ // https://webperf-snippets.net
3
3
 
4
4
  (() => {
5
5
  const connection =
@@ -207,6 +207,8 @@ A script wrapped in `void (async () => {...})()` discards its return value. Neve
207
207
  }
208
208
  ```
209
209
 
210
+ `details.element` is a CSS selector that matches only the LCP element, so `document.querySelector(details.element)` returns it: the id when it is unique, otherwise a path of tag, classes and `:nth-of-type()` from the nearest ancestor with a unique id, such as `section.intro:nth-of-type(2) > p.copy:nth-of-type(2)`. LCP-Subparts (`details.element`) and LCP-Trail (`finalElement` and each `selector`) build it the same way.
211
+
210
212
  #### CLS
211
213
 
212
214
  Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
@@ -421,7 +423,16 @@ If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "g
421
423
  "serverResponse": { "value": 165, "unit": "ms" }
422
424
  },
423
425
  "slowestPhase": "serverResponse"
424
- }
426
+ },
427
+ "issues": []
428
+ }
429
+ ```
430
+
431
+ When the rating is not good, `issues` holds one entry that names the TTFB and the slowest phase, a `warning` for `needs-improvement` and an `error` for `poor`:
432
+
433
+ ```json
434
+ {
435
+ "issues": [{ "severity": "warning", "message": "TTFB is 996 ms (needs-improvement); server response takes 993 ms of it (100%). Fix: optimize server processing, cache the HTML and check slow queries." }]
425
436
  }
426
437
  ```
427
438
 
@@ -744,7 +755,7 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
744
755
 
745
756
  #### CSS-Media-Queries-Analysis
746
757
 
747
- `details.performanceImpact` estimates the cost of the desktop-only CSS for each device profile, in numbers rounded to one decimal. It is `null` when there is no desktop-only CSS. The detailed console report stays opt-in through `analyzeCSSPerformanceImpact()`.
758
+ One item per distinct media query that targets viewports wider than `details.minWidth`, largest first. `details` gives the totals, `totalUnique` and `uniquenessRatio`, and `bySource` breaks the rules down by stylesheet.
748
759
 
749
760
  ```json
750
761
  {
@@ -752,16 +763,21 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
752
763
  "status": "ok",
753
764
  "count": 2,
754
765
  "details": {
755
- "corsBlockedCount": 0,
756
- "performanceImpact": {
757
- "unnecessaryBytes": 1200,
758
- "totalClasses": 4,
759
- "totalProperties": 9,
760
- "deviceImpact": {
761
- "Mid-range (Moto G Power, iPhone SE)": { "renderBlockingTimeMs": 3.1, "runtimeOverheadMs": 0.1, "fcpImpactMs": 1.9, "lcpImpactMs": 1.2, "inpOverheadMs": 0.1 }
762
- }
763
- }
764
- }
766
+ "minWidth": 768,
767
+ "rules": 3,
768
+ "totalUnique": 2,
769
+ "uniquenessRatio": 0.667,
770
+ "classes": 4,
771
+ "properties": 5,
772
+ "bytes": 160,
773
+ "bySource": [{ "source": "https://example.com/app.css", "inline": false, "rules": 3, "bytes": 160 }],
774
+ "corsBlockedCount": 0
775
+ },
776
+ "items": [
777
+ { "mediaQuery": "(min-width: 1024px)", "rules": 2, "files": 1, "classes": 3, "properties": 3, "bytes": 110 },
778
+ { "mediaQuery": "(min-width: 1200px)", "rules": 1, "files": 1, "classes": 1, "properties": 2, "bytes": 50 }
779
+ ],
780
+ "issues": [{ "severity": "info", "message": "3 @media rules for viewports wider than 768px add 160.0 B of CSS that narrower screens download and parse without using. ..." }]
765
781
  }
766
782
  ```
767
783
 
@@ -1089,7 +1105,7 @@ Same shape as Image-Element-Audit but for video elements.
1089
1105
 
1090
1106
  #### SVG-Embedded-Bitmap-Analysis
1091
1107
 
1092
- `details` counts the SVGs, also on a page with none. `spriteOpportunity` is `true` when five or more standalone inline SVGs could share one `<symbol>` sprite.
1108
+ `details` counts the SVGs, also on a page with none. `spriteOpportunity` is `true` when five or more standalone inline SVGs could share one `<symbol>` sprite, and it is then also an `info` issue with the counts.
1093
1109
 
1094
1110
  ```json
1095
1111
  {
@@ -1098,7 +1114,10 @@ Same shape as Image-Element-Audit but for video elements.
1098
1114
  "count": 2,
1099
1115
  "details": { "externalSvgCount": 3, "inlineSvgTotal": 7, "svgsWithUse": 1, "standaloneInlineSvgs": 6, "spriteOpportunity": true },
1100
1116
  "items": [{ "url": "icon.svg", "hasBitmap": true, "bitmapType": "image/png", "sizeBytes": 4500 }],
1101
- "issues": [{ "severity": "warning", "message": "2 SVG files contain embedded bitmaps" }]
1117
+ "issues": [
1118
+ { "severity": "warning", "message": "2 SVG file(s) contain embedded bitmaps" },
1119
+ { "severity": "info", "message": "6 standalone inline SVGs could share one <symbol> sprite referenced with <use> (1 inline SVG already uses <use>)" }
1120
+ ]
1102
1121
  }
1103
1122
  ```
1104
1123
 
@@ -59,3 +59,62 @@ function formatBytes(bytes) {
59
59
  return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
60
60
  }
61
61
  // @end-shared formatBytes
62
+
63
+ // @shared ownRequestUrl
64
+ // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
65
+ // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
66
+ // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
67
+ // which is how isOwnRequest leaves these requests out of what the page loaded.
68
+ function ownRequestUrl(url) {
69
+ try {
70
+ const parsed = new URL(url, location.href);
71
+ if (!/^https?:$/.test(parsed.protocol)) return url;
72
+ parsed.hash = "webperf-snippets";
73
+ return parsed.href;
74
+ } catch {
75
+ return url;
76
+ }
77
+ }
78
+ // @end-shared ownRequestUrl
79
+
80
+ // @shared isOwnRequest
81
+ // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
82
+ // did not make it, so no snippet counts it.
83
+ function isOwnRequest(entry) {
84
+ return entry.name.endsWith("#webperf-snippets");
85
+ }
86
+ // @end-shared isOwnRequest
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
@@ -38,3 +38,30 @@ export async function runInteractions(page, source) {
38
38
  await executeStep(page, step);
39
39
  }
40
40
  }
41
+
42
+ // The browser reports the timing of an event after the frame that follows it, and an observer gets it
43
+ // in a task after that frame. Reading a snippet right after the last step can miss that step, so this
44
+ // waits for two frames and a task, which takes a few milliseconds on a page that renders. A page that
45
+ // never renders a frame stops the wait at `limitMs`, and a page that is gone is not an error here: the
46
+ // evaluation that follows reports it.
47
+ export async function settle(page, limitMs = 1000) {
48
+ try {
49
+ await page.evaluate(
50
+ (limit) =>
51
+ new Promise((resolve) => {
52
+ const timer = setTimeout(resolve, limit);
53
+ requestAnimationFrame(() =>
54
+ requestAnimationFrame(() =>
55
+ setTimeout(() => {
56
+ clearTimeout(timer);
57
+ resolve();
58
+ }, 0)
59
+ )
60
+ );
61
+ }),
62
+ limitMs
63
+ );
64
+ } catch {
65
+ // the page navigated or closed
66
+ }
67
+ }
package/src/runner.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { chromium } from "playwright";
2
2
  import { loadSnippet } from "./load-snippet.js";
3
- import { runInteractions, loadInteractionSteps, isInputStep } from "./interactions.js";
3
+ import { runInteractions, loadInteractionSteps, isInputStep, settle } from "./interactions.js";
4
4
  import { isTrackingSnippet } from "./tracking.js";
5
5
  import { nextSteps } from "./decision-tree.js";
6
6
 
@@ -42,14 +42,16 @@ async function evaluateItems(page, items) {
42
42
 
43
43
  // Runs the items around a set of interactions. A tracking snippet is installed first, so it sees
44
44
  // the interactions, and answers through its getDataFn afterwards. The other items run after the
45
- // interactions, as before. Results keep the order of the items. Without interactions every item
46
- // runs once, as it always did.
45
+ // interactions, as before. The browser reports the timing of an interaction after the frame that
46
+ // follows it, so the runner lets it settle before it reads anything. Results keep the order of the
47
+ // items. Without interactions every item runs once, as it always did.
47
48
  async function evaluateAroundInteractions(page, items, interactions) {
48
49
  if (!interactions) return evaluateItems(page, items);
49
50
 
50
51
  const tracking = items.filter((item) => isTrackingSnippet(item.path ?? "", item.source));
51
52
  const installed = await evaluateItems(page, tracking);
52
53
  await runInteractions(page, interactions);
54
+ await settle(page);
53
55
  const others = await evaluateItems(
54
56
  page,
55
57
  items.filter((item) => !tracking.includes(item))
package/src/tracking.js CHANGED
@@ -1,6 +1,6 @@
1
- // A tracking snippet installs observers and answers later through a window function named in
2
- // its result (getDataFn). The Interaction snippets work this way. CLS and INP also name a
3
- // getDataFn but answer with their value in one go, so only the Interaction category counts.
1
+ // A tracking snippet installs observers and answers later through a window function named in its
2
+ // result (getDataFn). The Interaction snippets work this way, and so does INP, whose value only
3
+ // comes out of getINP(). CLS also names a getDataFn but answers with its value in one go.
4
4
  export function isTrackingSnippet(path, source) {
5
- return path.startsWith("Interaction/") && source.includes("getDataFn");
5
+ return (path.startsWith("Interaction/") || path === "CoreWebVitals/INP") && source.includes("getDataFn");
6
6
  }