From d5539abddc358ea243ea1fc3e0c3b228406f13c7 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sat, 9 Mar 2024 06:10:18 +0000 Subject: [PATCH 01/33] Deprecate FID and remove other deprecated APIs (#435) * Deprecate FID and prep v4.0.0 * Forgot that I don't need to bumpo the versions * Review feedback * Missed one bit of feedback * All your base are belong to bin * Update attribution.d.ts --- README.md | 231 ++++--------------- attribution.d.ts | 2 +- base.d.ts | 65 ------ base.js | 18 -- package.json | 16 +- rollup.config.js | 62 +---- src/attribution/deprecated.ts | 26 +++ src/{attribution.ts => attribution/index.ts} | 25 +- src/deprecated.ts | 47 +--- src/index.ts | 1 - src/lib/getNavigationEntry.ts | 45 +--- src/lib/observe.ts | 9 +- src/onFID.ts | 36 +-- src/types/base.ts | 11 +- src/types/fcp.ts | 3 +- src/types/fid.ts | 8 +- src/types/lcp.ts | 3 +- src/types/polyfills.ts | 13 -- src/types/ttfb.ts | 5 +- test/e2e/onFID-test.js | 34 +-- test/server.js | 4 - test/views/layout.njk | 3 - 22 files changed, 116 insertions(+), 551 deletions(-) delete mode 100644 base.d.ts delete mode 100644 base.js create mode 100644 src/attribution/deprecated.ts rename src/{attribution.ts => attribution/index.ts} (51%) diff --git a/README.md b/README.md index 55db7669..5f01f854 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ - [Batch multiple reports together](#batch-multiple-reports-together) - [Build options](#build-options) - [Which build is right for you?](#which-build-is-right-for-you) - - [How the polyfill works](#how-the-polyfill-works) - [API](#api) - [Types](#types) - [Functions](#functions) @@ -36,14 +35,14 @@ The library supports all of the [Core Web Vitals](https://web.dev/articles/vital ### Core Web Vitals - [Cumulative Layout Shift (CLS)](https://web.dev/articles/cls) -- [First Input Delay (FID)](https://web.dev/articles/fid) +- [Interaction to Next Paint (INP)](https://web.dev/articles/inp) - [Largest Contentful Paint (LCP)](https://web.dev/articles/lcp) ### Other metrics -- [Interaction to next Paint (INP)](https://web.dev/articles/inp) - [First Contentful Paint (FCP)](https://web.dev/articles/fcp) - [Time to First Byte (TTFB)](https://web.dev/articles/ttfb) +- [First Input Delay (FID)](https://web.dev/articles/fid) _Deprecated and will be removed in next major release_ @@ -75,10 +74,10 @@ For details on the difference between the builds, see - -**3. The "base+polyfill" build** - -_**⚠️ Warning ⚠️** the "base+polyfill" build is deprecated. See [#238](https://github.com/GoogleChrome/web-vitals/issues/238) for details._ - -Loading the "base+polyfill" build is a two-step process: - -First, in your application code, import the "base" build rather than the "standard" build. To do this, change any `import` statements that reference `web-vitals` to `web-vitals/base`: - -```diff -- import {onLCP, onFID, onCLS} from 'web-vitals'; -+ import {onLCP, onFID, onCLS} from 'web-vitals/base'; -``` - -Then, inline the code from `dist/polyfill.js` into the `` of your pages. This step is important since the "base" build will error if the polyfill code has not been added. - -```html - - - - - - - ... - - -``` - -It's important that the code is inlined directly into the HTML. _Do not link to an external script file, as that will negatively affect performance:_ - -```html - - - - - -``` - -Also note that the code _must_ go in the `` of your pages in order to work. See [how the polyfill works](#how-the-polyfill-works) for more details. - -_**Tip:** while it's certainly possible to inline the code in `dist/polyfill.js` by copy and pasting it directly into your templates, it's better to automate this process in a build step—otherwise you risk the "base" and the "polyfill" scripts getting out of sync when new versions are released._ - ### From a CDN @@ -167,10 +119,10 @@ _**Important!** The [unpkg.com](https://unpkg.com) CDN is shown here for example ```html ``` @@ -181,12 +133,12 @@ _**Important!** The [unpkg.com](https://unpkg.com) CDN is shown here for example ``` @@ -218,12 +170,12 @@ _**Important!** The [unpkg.com](https://unpkg.com) CDN is shown here for example (function () { var script = document.createElement('script'); script.src = - 'https://unpkg.com/web-vitals@3/dist/web-vitals.attribution.iife.js'; + 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.iife.js'; script.onload = function () { // When loading `web-vitals` using a classic script, all the public // methods can be found on the `webVitals` global namespace. webVitals.onCLS(console.log); - webVitals.onFID(console.log); + webVitals.onINP(console.log); webVitals.onLCP(console.log); }; document.head.appendChild(script); @@ -242,10 +194,10 @@ The following example measures each of the Core Web Vitals metrics and logs the _(The examples below import the "standard" build, but they will work with the "attribution" build as well.)_ ```js -import {onCLS, onFID, onLCP} from 'web-vitals'; +import {onCLS, onINP, onLCP} from 'web-vitals'; onCLS(console.log); -onFID(console.log); +onINP(console.log); onLCP(console.log); ``` @@ -261,7 +213,7 @@ In other cases, a metric callback may be called more than once: - CLS and INP should be reported any time the [page's `visibilityState` changes to hidden](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden). - All metrics are reported again (with the above exceptions) after a page is restored from the [back/forward cache](https://web.dev/articles/bfcache). -_**Warning:** do not call any of the Web Vitals functions (e.g. `onCLS()`, `onFID()`, `onLCP()`) more than once per page load. Each of these functions creates a `PerformanceObserver` instance and registers event listeners for the lifetime of the page. While the overhead of calling these functions once is negligible, calling them repeatedly on the same page may eventually result in a memory leak._ +_**Warning:** do not call any of the Web Vitals functions (e.g. `onCLS()`, `onINP()`, `onLCP()`) more than once per page load. Each of these functions creates a `PerformanceObserver` instance and registers event listeners for the lifetime of the page. While the overhead of calling these functions once is negligible, calling them repeatedly on the same page may eventually result in a memory leak._ ### Report the value on every change @@ -287,14 +239,14 @@ Other analytics providers, however, do not allow this, so instead of reporting t The following example shows how to use the `id` and `delta` properties: ```js -import {onCLS, onFID, onLCP} from 'web-vitals'; +import {onCLS, onINP, onLCP} from 'web-vitals'; function logDelta({name, id, delta}) { console.log(`${name} matching ID ${id} changed by ${delta}`); } onCLS(logDelta); -onFID(logDelta); +onINP(logDelta); onLCP(logDelta); ``` @@ -309,7 +261,7 @@ The following example measures each of the Core Web Vitals metrics and reports t The `sendToAnalytics()` function uses the [`navigator.sendBeacon()`](https://developer.mozilla.org/docs/Web/API/Navigator/sendBeacon) method (if available), but falls back to the [`fetch()`](https://developer.mozilla.org/docs/Web/API/Fetch_API) API when not. ```js -import {onCLS, onFID, onLCP} from 'web-vitals'; +import {onCLS, onINP, onLCP} from 'web-vitals'; function sendToAnalytics(metric) { // Replace with whatever serialization method you prefer. @@ -322,7 +274,7 @@ function sendToAnalytics(metric) { } onCLS(sendToAnalytics); -onFID(sendToAnalytics); +onINP(sendToAnalytics); onLCP(sendToAnalytics); ``` @@ -333,7 +285,7 @@ Google Analytics does not support reporting metric distributions in any of its b [Google Analytics 4](https://support.google.com/analytics/answer/10089681) introduces a new Event model allowing custom parameters instead of a fixed category, action, and label. It also supports non-integer values, making it easier to measure Web Vitals metrics compared to previous versions. ```js -import {onCLS, onFID, onLCP} from 'web-vitals'; +import {onCLS, onINP, onLCP} from 'web-vitals'; function sendToGoogleAnalytics({name, delta, value, id}) { // Assumes the global `gtag()` function exists, see: @@ -355,7 +307,7 @@ function sendToGoogleAnalytics({name, delta, value, id}) { } onCLS(sendToGoogleAnalytics); -onFID(sendToGoogleAnalytics); +onINP(sendToGoogleAnalytics); onLCP(sendToGoogleAnalytics); ``` @@ -375,7 +327,7 @@ When using the [attribution build](#attribution-build), you can send additional This example sends an additional `debug_target` param to Google Analytics, corresponding to the element most associated with each metric. ```js -import {onCLS, onFID, onLCP} from 'web-vitals/attribution'; +import {onCLS, onINP, onLCP} from 'web-vitals/attribution'; function sendToGoogleAnalytics({name, delta, value, id, attribution}) { const eventParams = { @@ -391,7 +343,7 @@ function sendToGoogleAnalytics({name, delta, value, id, attribution}) { case 'CLS': eventParams.debug_target = attribution.largestShiftTarget; break; - case 'FID': + case 'INP': eventParams.debug_target = attribution.eventTarget; break; case 'LCP': @@ -405,7 +357,7 @@ function sendToGoogleAnalytics({name, delta, value, id, attribution}) { } onCLS(sendToGoogleAnalytics); -onFID(sendToGoogleAnalytics); +onINP(sendToGoogleAnalytics); onLCP(sendToGoogleAnalytics); ``` @@ -422,7 +374,7 @@ However, since not all Web Vitals metrics become available at the same time, and Instead, you should keep a queue of all metrics that were reported and flush the queue whenever the page is backgrounded or unloaded: ```js -import {onCLS, onFID, onLCP} from 'web-vitals'; +import {onCLS, onINP, onLCP} from 'web-vitals'; const queue = new Set(); function addToQueue(metric) { @@ -444,7 +396,7 @@ function flushQueue() { } onCLS(addToQueue); -onFID(addToQueue); +onINP(addToQueue); onLCP(addToQueue); // Report all available metrics whenever the page is backgrounded or unloaded. @@ -466,7 +418,7 @@ _**Note:** see [the Page Lifecycle guide](https://developers.google.com/web/upda ## Build options -The `web-vitals` package includes builds for the "standard", "attribution", and "base+polyfill" ([deprecated](https://github.com/GoogleChrome/web-vitals/issues/238)) builds, as well as different formats of each to allow developers to choose the format that best meets their needs or integrates with their architecture. +The `web-vitals` package includes both "standard" and "attribution" builds, as well as different formats of each to allow developers to choose the format that best meets their needs or integrates with their architecture. The following table lists all the builds distributed with the `web-vitals` package on npm. @@ -522,41 +474,6 @@ The following table lists all the builds distributed with the `web-vitals` packa An IIFE version of the web-vitals.attribution.js build (exposed on the window.webVitals.* namespace). - - web-vitals.base.js - -- - -

This build has been deprecated.

-

An ES module bundle containing just the "base" part of the "base+polyfill" version.

- Use this bundle if (and only if) you've also added the polyfill.js script to the <head> of your pages. See how to use the polyfill for more details. - - - - web-vitals.base.umd.cjs - -- - -

This build has been deprecated.

-

A UMD version of the web-vitals.base.js bundle (exposed on the window.webVitals.* namespace).

- - - - - web-vitals.base.iife.js - -- - -

This build has been deprecated.

-

An IIFE version of the web-vitals.base.js bundle (exposed on the window.webVitals.* namespace).

- - - - polyfill.js - -- - -

This build has been deprecated.

-

The "polyfill" part of the "base+polyfill" version. This script should be used with either web-vitals.base.js, web-vitals.base.umd.cjs, or web-vitals.base.iife.js (it will not work with any script that doesn't have "base" in the filename).

- See how to use the polyfill for more details. - - @@ -569,16 +486,6 @@ However, if you'd lke to collect additional debug information to help you diagno For guidance on how to collect and use real-user data to debug performance issues, see [Debug performance in the field](https://web.dev/debug-performance-in-the-field/). -### How the polyfill works - -_**⚠️ Warning ⚠️** the "base+polyfill" build is deprecated. See [#238](https://github.com/GoogleChrome/web-vitals/issues/238) for details._ - -The `polyfill.js` script adds event listeners (to track FID cross-browser), and it records initial page visibility state as well as the timestamp of the first visibility change to hidden (to improve the accuracy of CLS, FCP, LCP, and FID). It also polyfills the [Navigation Timing API Level 2](https://www.w3.org/TR/navigation-timing-2/) in browsers that only support the original (now deprecated) [Navigation Timing API](https://www.w3.org/TR/navigation-timing/). - -In order for the polyfill to work properly, the script must be the first script added to the page, and it must run before the browser renders any content to the screen. This is why it needs to be added to the `` of the document. - -The "standard" build of the `web-vitals` library includes some of the same logic found in `polyfill.js`. To avoid duplicating that code when using the "base+polyfill" build, the `web-vitals.base.js` bundle does not include any polyfill logic, instead it coordinates with the code in `polyfill.js`, which is why the two scripts must be used together. - ## API ### Types: @@ -625,12 +532,7 @@ interface Metric { * The array may also be empty if the metric value was not based on any * entries (e.g. a CLS value of 0 given no layout shifts). */ - entries: ( - | PerformanceEntry - | LayoutShift - | FirstInputPolyfillEntry - | NavigationTimingPolyfillEntry - )[]; + entries: (PerformanceEntry | LayoutShift)[]; /** * The type of navigation. @@ -759,56 +661,6 @@ type LoadState = | 'complete'; ``` -#### `FirstInputPolyfillEntry` - -If using the "base+polyfill" build (and if the browser doesn't natively support the Event Timing API), the `metric.entries` reported by `onFID()` will contain an object that polyfills the `PerformanceEventTiming` entry: - -```ts -type FirstInputPolyfillEntry = Omit< - PerformanceEventTiming, - 'processingEnd' | 'toJSON' ->; -``` - -#### `FirstInputPolyfillCallback` - -```ts -interface FirstInputPolyfillCallback { - (entry: FirstInputPolyfillEntry): void; -} -``` - -#### `NavigationTimingPolyfillEntry` - -If using the "base+polyfill" build (and if the browser doesn't support the [Navigation Timing API Level 2](https://www.w3.org/TR/navigation-timing-2/) interface), the `metric.entries` reported by `onTTFB()` will contain an object that polyfills the `PerformanceNavigationTiming` entry using timings from the legacy `performance.timing` interface: - -```ts -type NavigationTimingPolyfillEntry = Omit< - PerformanceNavigationTiming, - | 'initiatorType' - | 'nextHopProtocol' - | 'redirectCount' - | 'transferSize' - | 'encodedBodySize' - | 'decodedBodySize' - | 'type' -> & { - type: PerformanceNavigationTiming['type']; -}; -``` - -#### `WebVitalsGlobal` - -If using the "base+polyfill" build, the `polyfill.js` script creates the global `webVitals` namespace matching the following interface: - -```ts -interface WebVitalsGlobal { - firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; - resetFirstInputPolyfill: () => void; - firstHiddenTime: number; -} -``` - ### Functions: #### `onCLS()` @@ -833,6 +685,8 @@ Calculates the [FCP](https://web.dev/articles/fcp) value for the current page an #### `onFID()` +_Deprecated and will be removed in next major release_ + ```ts type onFID = (callback: FIDReportCallback, opts?: ReportOpts) => void; ``` @@ -898,10 +752,10 @@ The thresholds of each metric's "good", "needs improvement", and "poor" ratings Example: ```ts -import {CLSThresholds, FIDThresholds, LCPThresholds} from 'web-vitals'; +import {CLSThresholds, INPThresholds, LCPThresholds} from 'web-vitals'; console.log(CLSThresholds); // [ 0.1, 0.25 ] -console.log(FIDThresholds); // [ 100, 300 ] +console.log(INPThresholds); // [ 200, 500 ] console.log(LCPThresholds); // [ 2500, 4000 ] ``` @@ -982,7 +836,7 @@ interface FCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } ``` @@ -1005,10 +859,9 @@ interface FIDAttribution { */ eventType: string; /** - * The `PerformanceEventTiming` entry corresponding to FID (or the - * polyfill entry in browsers that don't support Event Timing). + * The `PerformanceEventTiming` entry corresponding to FID. */ - eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry; + eventEntry: PerformanceEventTiming; /** * The loading state of the document at the time when the first interaction * occurred (see `LoadState` for details). If the first interaction occurred @@ -1094,7 +947,7 @@ interface LCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. @@ -1136,7 +989,7 @@ interface TTFBAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } ``` @@ -1148,10 +1001,10 @@ Browser support for each function is as follows: - `onCLS()`: Chromium - `onFCP()`: Chromium, Firefox, Safari 14.1+ -- `onFID()`: Chromium, Firefox _(with [polyfill](#how-to-use-the-polyfill): Safari, Internet Explorer)_ +- `onFID()`: Chromium, Firefox _(Deprecated)_ - `onINP()`: Chromium - `onLCP()`: Chromium -- `onTTFB()`: Chromium, Firefox, Safari 15+ _(with [polyfill](#how-to-use-the-polyfill): Safari 8+, Internet Explorer)_ +- `onTTFB()`: Chromium, Firefox, Safari 15+ ## Limitations diff --git a/attribution.d.ts b/attribution.d.ts index 1cdb5204..eef78ebc 100644 --- a/attribution.d.ts +++ b/attribution.d.ts @@ -13,4 +13,4 @@ limitations under the License. */ -export * from './dist/modules/attribution.js'; +export * from './dist/modules/attribution/index.js'; diff --git a/base.d.ts b/base.d.ts deleted file mode 100644 index f15dc001..00000000 --- a/base.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - Copyright 2022 Google LLC - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onCLS, -} from './dist/modules/index.js'; - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onFCP, -} from './dist/modules/index.js'; - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onFID, -} from './dist/modules/index.js'; - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onINP, -} from './dist/modules/index.js'; - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onLCP, -} from './dist/modules/index.js'; - -export { - /** - * @deprecated The "base+polyfill" build is deprecated. - * See: https://bit.ly/3aqzsGm - */ - onTTFB, -} from './dist/modules/index.js'; - -export * from './dist/modules/deprecated.js'; -export * from './dist/modules/types.js'; diff --git a/base.js b/base.js deleted file mode 100644 index ae7bfd33..00000000 --- a/base.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - Copyright 2022 Google LLC - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Creates the `web-vitals/base` import in node-based bundlers. -// This will not be needed when export maps are widely supported. -export * from './dist/web-vitals.base.js'; diff --git a/package.json b/package.json index 4080f036..98a61489 100644 --- a/package.json +++ b/package.json @@ -13,23 +13,13 @@ "require": "./dist/web-vitals.umd.cjs", "default": "./dist/web-vitals.js" }, - "./base": { - "types": "./base.d.ts", - "require": "./dist/web-vitals.base.umd.cjs", - "default": "./dist/web-vitals.base.js" - }, - "./base.js": { - "types": "./base.d.ts", - "require": "./dist/web-vitals.base.umd.cjs", - "default": "./dist/web-vitals.base.js" - }, "./attribution": { - "types": "./dist/modules/attribution.d.ts", + "types": "./dist/modules/attribution/index.d.ts", "require": "./dist/web-vitals.attribution.umd.cjs", "default": "./dist/web-vitals.attribution.js" }, "./attribution.js": { - "types": "./dist/modules/attribution.d.ts", + "types": "./dist/modules/attribution/index.d.ts", "require": "./dist/web-vitals.attribution.umd.cjs", "default": "./dist/web-vitals.attribution.js" }, @@ -85,8 +75,6 @@ "files": [ "attribution.js", "attribution.d.ts", - "base.js", - "base.d.ts", "dist", "src" ], diff --git a/rollup.config.js b/rollup.config.js index d1533d06..1d1e0426 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,7 +17,7 @@ import babel from '@rollup/plugin-babel'; import replace from '@rollup/plugin-replace'; import terser from '@rollup/plugin-terser'; -const configurePlugins = ({module, polyfill = false}) => { +const configurePlugins = ({module}) => { return [ babel({ babelHelpers: 'bundled', @@ -37,12 +37,6 @@ const configurePlugins = ({module, polyfill = false}) => { mangle: true, compress: true, }), - replace({ - values: { - 'window.__WEB_VITALS_POLYFILL__': polyfill, - }, - preventAssignment: true, - }), ]; }; @@ -53,7 +47,7 @@ const configs = [ format: 'esm', file: './dist/web-vitals.js', }, - plugins: configurePlugins({module: true, polyfill: false}), + plugins: configurePlugins({module: true}), }, { input: 'dist/modules/index.js', @@ -62,7 +56,7 @@ const configs = [ file: `./dist/web-vitals.umd.cjs`, name: 'webVitals', }, - plugins: configurePlugins({module: false, polyfill: false}), + plugins: configurePlugins({module: false}), }, { input: 'dist/modules/index.js', @@ -71,71 +65,33 @@ const configs = [ file: './dist/web-vitals.iife.js', name: 'webVitals', }, - plugins: configurePlugins({module: false, polyfill: false}), - }, - { - input: 'dist/modules/index.js', - output: { - format: 'esm', - file: './dist/web-vitals.base.js', - }, - plugins: configurePlugins({module: true, polyfill: true}), - }, - { - input: 'dist/modules/index.js', - output: { - format: 'umd', - file: `./dist/web-vitals.base.umd.cjs`, - name: 'webVitals', - extend: true, - }, - plugins: configurePlugins({module: false, polyfill: true}), - }, - { - input: 'dist/modules/index.js', - output: { - format: 'iife', - file: `./dist/web-vitals.base.iife.js`, - name: 'webVitals', - extend: true, - }, - plugins: configurePlugins({module: false, polyfill: true}), - }, - { - input: 'dist/modules/polyfill.js', - output: { - format: 'iife', - file: './dist/polyfill.js', - name: 'webVitals', - strict: false, - }, plugins: configurePlugins({module: false}), }, { - input: 'dist/modules/attribution.js', + input: 'dist/modules/attribution/index.js', output: { format: 'esm', file: './dist/web-vitals.attribution.js', }, - plugins: configurePlugins({module: true, polyfill: false}), + plugins: configurePlugins({module: true}), }, { - input: 'dist/modules/attribution.js', + input: 'dist/modules/attribution/index.js', output: { format: 'umd', file: `./dist/web-vitals.attribution.umd.cjs`, name: 'webVitals', }, - plugins: configurePlugins({module: false, polyfill: false}), + plugins: configurePlugins({module: false}), }, { - input: 'dist/modules/attribution.js', + input: 'dist/modules/attribution/index.js', output: { format: 'iife', file: './dist/web-vitals.attribution.iife.js', name: 'webVitals', }, - plugins: configurePlugins({module: false, polyfill: false}), + plugins: configurePlugins({module: false}), }, ]; diff --git a/src/attribution/deprecated.ts b/src/attribution/deprecated.ts new file mode 100644 index 00000000..826ebfc9 --- /dev/null +++ b/src/attribution/deprecated.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + /** + * @deprecated Use `onINP()` instead. + */ + onFID, +} from './onFID.js'; + +export {FIDThresholds} from '../onFID.js'; + +export * from '../types.js'; diff --git a/src/attribution.ts b/src/attribution/index.ts similarity index 51% rename from src/attribution.ts rename to src/attribution/index.ts index 89fe99a0..c0362821 100644 --- a/src/attribution.ts +++ b/src/attribution/index.ts @@ -14,18 +14,17 @@ * limitations under the License. */ -export {onCLS} from './attribution/onCLS.js'; -export {onFCP} from './attribution/onFCP.js'; -export {onFID} from './attribution/onFID.js'; -export {onINP} from './attribution/onINP.js'; -export {onLCP} from './attribution/onLCP.js'; -export {onTTFB} from './attribution/onTTFB.js'; +export {onCLS} from './onCLS.js'; +export {onFCP} from './onFCP.js'; +export {onINP} from './onINP.js'; +export {onLCP} from './onLCP.js'; +export {onTTFB} from './onTTFB.js'; -export {CLSThresholds} from './onCLS.js'; -export {FCPThresholds} from './onFCP.js'; -export {FIDThresholds} from './onFID.js'; -export {INPThresholds} from './onINP.js'; -export {LCPThresholds} from './onLCP.js'; -export {TTFBThresholds} from './onTTFB.js'; +export {CLSThresholds} from '../onCLS.js'; +export {FCPThresholds} from '../onFCP.js'; +export {INPThresholds} from '../onINP.js'; +export {LCPThresholds} from '../onLCP.js'; +export {TTFBThresholds} from '../onTTFB.js'; -export * from './types.js'; +export * from './deprecated.js'; +export * from '../types.js'; diff --git a/src/deprecated.ts b/src/deprecated.ts index 5bb254d8..1acc67e3 100644 --- a/src/deprecated.ts +++ b/src/deprecated.ts @@ -14,51 +14,10 @@ * limitations under the License. */ -export { - /** - * @deprecated Use `onCLS()` instead. - */ - onCLS as getCLS, -} from './onCLS.js'; - -export { - /** - * @deprecated Use `onFCP()` instead. - */ - onFCP as getFCP, -} from './onFCP.js'; - -export { - /** - * @deprecated Use `onFID()` instead. - */ - onFID as getFID, -} from './onFID.js'; - export { /** * @deprecated Use `onINP()` instead. */ - onINP as getINP, -} from './onINP.js'; - -export { - /** - * @deprecated Use `onLCP()` instead. - */ - onLCP as getLCP, -} from './onLCP.js'; - -export { - /** - * @deprecated Use `onTTFB()` instead. - */ - onTTFB as getTTFB, -} from './onTTFB.js'; - -export { - /** - * @deprecated Use `ReportCallback` instead. - */ - ReportCallback as ReportHandler, -} from './types.js'; + onFID, + FIDThresholds, +} from './onFID.js'; diff --git a/src/index.ts b/src/index.ts index 22ea7c91..6c5dc45d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ export {onCLS, CLSThresholds} from './onCLS.js'; export {onFCP, FCPThresholds} from './onFCP.js'; -export {onFID, FIDThresholds} from './onFID.js'; export {onINP, INPThresholds} from './onINP.js'; export {onLCP, LCPThresholds} from './onLCP.js'; export {onTTFB, TTFBThresholds} from './onTTFB.js'; diff --git a/src/lib/getNavigationEntry.ts b/src/lib/getNavigationEntry.ts index fa523bdf..3a3e8fd3 100644 --- a/src/lib/getNavigationEntry.ts +++ b/src/lib/getNavigationEntry.ts @@ -14,47 +14,12 @@ * limitations under the License. */ -import {NavigationTimingPolyfillEntry} from '../types.js'; - -const getNavigationEntryFromPerformanceTiming = - (): NavigationTimingPolyfillEntry => { - const timing = performance.timing; - const type = performance.navigation.type; - - const navigationEntry: {[key: string]: number | string} = { - entryType: 'navigation', - startTime: 0, - type: type == 2 ? 'back_forward' : type === 1 ? 'reload' : 'navigate', - }; - - for (const key in timing) { - if (key !== 'navigationStart' && key !== 'toJSON') { - navigationEntry[key] = Math.max( - (timing[key as keyof PerformanceTiming] as number) - - timing.navigationStart, - 0, - ); - } - } - return navigationEntry as unknown as NavigationTimingPolyfillEntry; - }; - export const getNavigationEntry = (): | PerformanceNavigationTiming - | NavigationTimingPolyfillEntry | undefined => { - if (window.__WEB_VITALS_POLYFILL__) { - return ( - window.performance && - ((performance.getEntriesByType && - performance.getEntriesByType('navigation')[0]) || - getNavigationEntryFromPerformanceTiming()) - ); - } else { - return ( - window.performance && - performance.getEntriesByType && - performance.getEntriesByType('navigation')[0] - ); - } + return ( + window.performance && + performance.getEntriesByType && + performance.getEntriesByType('navigation')[0] + ); }; diff --git a/src/lib/observe.ts b/src/lib/observe.ts index fca60989..af63f068 100644 --- a/src/lib/observe.ts +++ b/src/lib/observe.ts @@ -14,18 +14,13 @@ * limitations under the License. */ -import { - FirstInputPolyfillEntry, - NavigationTimingPolyfillEntry, -} from '../types.js'; - interface PerformanceEntryMap { 'event': PerformanceEventTiming[]; 'paint': PerformancePaintTiming[]; 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; - 'first-input': PerformanceEventTiming[] | FirstInputPolyfillEntry[]; - 'navigation': PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; + 'first-input': PerformanceEventTiming[]; + 'navigation': PerformanceNavigationTiming[]; 'resource': PerformanceResourceTiming[]; } diff --git a/src/onFID.ts b/src/onFID.ts index c9ee655e..e3e3c327 100644 --- a/src/onFID.ts +++ b/src/onFID.ts @@ -69,6 +69,7 @@ export const onFID = (onReport: FIDReportCallback, opts?: ReportOpts) => { }; const po = observe('first-input', handleEntries); + report = bindReporter( onReport, metric, @@ -83,19 +84,7 @@ export const onFID = (onReport: FIDReportCallback, opts?: ReportOpts) => { po.disconnect(); }), ); - } - if (window.__WEB_VITALS_POLYFILL__) { - console.warn( - 'The web-vitals "base+polyfill" build is deprecated. See: https://bit.ly/3aqzsGm', - ); - - // Prefer the native implementation if available, - if (!po) { - window.webVitals.firstInputPolyfill( - handleEntry as FirstInputPolyfillCallback, - ); - } onBFCacheRestore(() => { metric = initMetric('FID'); report = bindReporter( @@ -105,27 +94,10 @@ export const onFID = (onReport: FIDReportCallback, opts?: ReportOpts) => { opts!.reportAllChanges, ); - window.webVitals.resetFirstInputPolyfill(); - window.webVitals.firstInputPolyfill( - handleEntry as FirstInputPolyfillCallback, - ); + // Browsers don't re-emit FID on bfcache restore so fake it until you make it + resetFirstInputPolyfill(); + firstInputPolyfill(handleEntry as FirstInputPolyfillCallback); }); - } else { - // Only monitor bfcache restores if the browser supports FID natively. - if (po) { - onBFCacheRestore(() => { - metric = initMetric('FID'); - report = bindReporter( - onReport, - metric, - FIDThresholds, - opts!.reportAllChanges, - ); - - resetFirstInputPolyfill(); - firstInputPolyfill(handleEntry as FirstInputPolyfillCallback); - }); - } } }); }; diff --git a/src/types/base.ts b/src/types/base.ts index c38660ef..f19f7081 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - FirstInputPolyfillEntry, - NavigationTimingPolyfillEntry, -} from './polyfills.js'; import type {CLSMetric} from './cls.js'; import type {FCPMetric} from './fcp.js'; import type {FIDMetric} from './fid.js'; @@ -64,12 +60,7 @@ export interface Metric { * The array may also be empty if the metric value was not based on any * entries (e.g. a CLS value of 0 given no layout shifts). */ - entries: ( - | PerformanceEntry - | LayoutShift - | FirstInputPolyfillEntry - | NavigationTimingPolyfillEntry - )[]; + entries: (PerformanceEntry | LayoutShift)[]; /** * The type of navigation. diff --git a/src/types/fcp.ts b/src/types/fcp.ts index ac9e3066..86d48b33 100644 --- a/src/types/fcp.ts +++ b/src/types/fcp.ts @@ -15,7 +15,6 @@ */ import type {LoadState, Metric} from './base.js'; -import {NavigationTimingPolyfillEntry} from './polyfills.js'; /** * An FCP-specific version of the Metric object. @@ -55,7 +54,7 @@ export interface FCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } /** diff --git a/src/types/fid.ts b/src/types/fid.ts index 4b664704..f6b71e75 100644 --- a/src/types/fid.ts +++ b/src/types/fid.ts @@ -15,14 +15,13 @@ */ import type {LoadState, Metric} from './base.js'; -import {FirstInputPolyfillEntry} from './polyfills.js'; /** * An FID-specific version of the Metric object. */ export interface FIDMetric extends Metric { name: 'FID'; - entries: (PerformanceEventTiming | FirstInputPolyfillEntry)[]; + entries: PerformanceEventTiming[]; } /** @@ -46,10 +45,9 @@ export interface FIDAttribution { */ eventType: string; /** - * The `PerformanceEventTiming` entry corresponding to FID (or the - * polyfill entry in browsers that don't support Event Timing). + * The `PerformanceEventTiming` entry corresponding to FID. */ - eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry; + eventEntry: PerformanceEventTiming; /** * The loading state of the document at the time when the first interaction * occurred (see `LoadState` for details). If the first interaction occurred diff --git a/src/types/lcp.ts b/src/types/lcp.ts index c32ca1d8..2128695b 100644 --- a/src/types/lcp.ts +++ b/src/types/lcp.ts @@ -15,7 +15,6 @@ */ import type {Metric} from './base.js'; -import {NavigationTimingPolyfillEntry} from './polyfills.js'; /** * An LCP-specific version of the Metric object. @@ -69,7 +68,7 @@ export interface LCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. diff --git a/src/types/polyfills.ts b/src/types/polyfills.ts index c174fe27..14bd5aea 100644 --- a/src/types/polyfills.ts +++ b/src/types/polyfills.ts @@ -22,16 +22,3 @@ export type FirstInputPolyfillEntry = Omit< export interface FirstInputPolyfillCallback { (entry: FirstInputPolyfillEntry): void; } - -export type NavigationTimingPolyfillEntry = Omit< - PerformanceNavigationTiming, - | 'initiatorType' - | 'nextHopProtocol' - | 'redirectCount' - | 'transferSize' - | 'encodedBodySize' - | 'decodedBodySize' - | 'type' -> & { - type: PerformanceNavigationTiming['type']; -}; diff --git a/src/types/ttfb.ts b/src/types/ttfb.ts index 4fe66eab..35a7f8c3 100644 --- a/src/types/ttfb.ts +++ b/src/types/ttfb.ts @@ -15,14 +15,13 @@ */ import type {Metric} from './base.js'; -import {NavigationTimingPolyfillEntry} from './polyfills.js'; /** * A TTFB-specific version of the Metric object. */ export interface TTFBMetric extends Metric { name: 'TTFB'; - entries: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; + entries: PerformanceNavigationTiming[]; } /** @@ -56,7 +55,7 @@ export interface TTFBAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } /** diff --git a/test/e2e/onFID-test.js b/test/e2e/onFID-test.js index b652107d..d90d7c3d 100644 --- a/test/e2e/onFID-test.js +++ b/test/e2e/onFID-test.js @@ -77,7 +77,7 @@ describe('onFID()', async function () { assert.match(fid.entries[0].name, /(mouse|pointer)down/); }); - it('does not report if the browser does not support FID and the polyfill is not used', async function () { + it('does not report if the browser does not support FID', async function () { if (browserSupportsFID) this.skip(); await navigateTo('/test/fid'); @@ -94,8 +94,7 @@ describe('onFID()', async function () { await stubForwardBack(); - // Assert no entries after bfcache restores either (if the browser does - // not support native FID and the polyfill is not used). + // Assert no entries after bfcache restores either. await h1.click(); // Wait a bit to ensure no beacons were sent. @@ -105,35 +104,6 @@ describe('onFID()', async function () { assert.strictEqual(bfcacheRestoreBeacons.length, 0); }); - it('falls back to the polyfill in non-supporting browsers', async function () { - // Ignore Safari until this bug is fixed: - // https://bugs.webkit.org/show_bug.cgi?id=211101 - if (browser.capabilities.browserName === 'Safari') this.skip(); - - await navigateTo('/test/fid?polyfill=1'); - - // Click on the

. - const h1 = await $('h1'); - await h1.click(); - - await beaconCountIs(1); - - const [fid] = await getBeacons(); - - assert(fid.value >= 0); - assert(fid.id.match(/^v3-\d+-\d+$/)); - assert.strictEqual(fid.name, 'FID'); - assert.strictEqual(fid.value, fid.delta); - assert.strictEqual(fid.rating, 'good'); - assert.match(fid.navigationType, /navigate|reload/); - assert.match(fid.entries[0].name, /(mouse|pointer)down/); - if (browserSupportsFID) { - assert('duration' in fid.entries[0]); - } else { - assert(!('duration' in fid.entries[0])); - } - }); - it('does not report if the document was hidden at page load time', async function () { // Ignore Safari until this bug is fixed: // https://bugs.webkit.org/show_bug.cgi?id=211101 diff --git a/test/server.js b/test/server.js index b6ea7731..2ac9098b 100644 --- a/test/server.js +++ b/test/server.js @@ -50,9 +50,6 @@ app.post('/collect', bodyParser.text(), (req, res) => { app.get('/test/:view', function (req, res, next) { let modulePath = `/dist/web-vitals.js`; - if (req.query.polyfill) { - modulePath = `/dist/web-vitals.base.js`; - } if (req.query.attribution) { modulePath = `/dist/web-vitals.attribution.js`; } @@ -60,7 +57,6 @@ app.get('/test/:view', function (req, res, next) { const data = { ...req.query, modulePath: modulePath, - webVitalsPolyfill: fs.readFileSync('./dist/polyfill.js', 'utf-8'), }; const content = nunjucks.render(`${req.params.view}.njk`, data); diff --git a/test/views/layout.njk b/test/views/layout.njk index 6ff783a1..b26934e0 100644 --- a/test/views/layout.njk +++ b/test/views/layout.njk @@ -223,9 +223,6 @@ }; }()); - {% if polyfill %} - - {% endif %} {% block head %}{% endblock %} {% if renderBlocking %} From 538cdf59be90c78a9da5c54baa471860dae61477 Mon Sep 17 00:00:00 2001 From: Philip Walton Date: Mon, 11 Mar 2024 04:04:43 -0700 Subject: [PATCH 02/33] Remove missed polyfill code (#441) * Remove missed polyfill code * Remove unused rollup plugin --- rollup.config.js | 1 - src/lib/getVisibilityWatcher.ts | 11 ++--------- src/polyfill.ts | 30 ------------------------------ src/types.ts | 21 --------------------- 4 files changed, 2 insertions(+), 61 deletions(-) delete mode 100644 src/polyfill.ts diff --git a/rollup.config.js b/rollup.config.js index 1d1e0426..e8cc5093 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -14,7 +14,6 @@ */ import babel from '@rollup/plugin-babel'; -import replace from '@rollup/plugin-replace'; import terser from '@rollup/plugin-terser'; const configurePlugins = ({module}) => { diff --git a/src/lib/getVisibilityWatcher.ts b/src/lib/getVisibilityWatcher.ts index 35f09400..ad7b1c3f 100644 --- a/src/lib/getVisibilityWatcher.ts +++ b/src/lib/getVisibilityWatcher.ts @@ -67,15 +67,8 @@ export const getVisibilityWatcher = () => { // since navigation start. This isn't a perfect heuristic, but it's the // best we can do until an API is available to support querying past // visibilityState. - if (window.__WEB_VITALS_POLYFILL__) { - firstHiddenTime = window.webVitals.firstHiddenTime; - if (firstHiddenTime === Infinity) { - addChangeListeners(); - } - } else { - firstHiddenTime = initHiddenTime(); - addChangeListeners(); - } + firstHiddenTime = initHiddenTime(); + addChangeListeners(); // Reset the time on bfcache restores. onBFCacheRestore(() => { diff --git a/src/polyfill.ts b/src/polyfill.ts deleted file mode 100644 index 4b286344..00000000 --- a/src/polyfill.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - firstInputPolyfill, - resetFirstInputPolyfill, -} from './lib/polyfills/firstInputPolyfill.js'; -import {getFirstHiddenTime} from './lib/polyfills/getFirstHiddenTimePolyfill.js'; - -resetFirstInputPolyfill(); -self.webVitals = { - firstInputPolyfill: firstInputPolyfill, - resetFirstInputPolyfill: resetFirstInputPolyfill, - get firstHiddenTime() { - return getFirstHiddenTime(); - }, -}; diff --git a/src/types.ts b/src/types.ts index 6346f65e..9246841e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import {FirstInputPolyfillCallback} from './types/polyfills.js'; - export * from './types/base.js'; export * from './types/polyfills.js'; @@ -26,25 +24,6 @@ export * from './types/inp.js'; export * from './types/lcp.js'; export * from './types/ttfb.js'; -// -------------------------------------------------------------------------- -// Web Vitals package globals -// -------------------------------------------------------------------------- - -export interface WebVitalsGlobal { - firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; - resetFirstInputPolyfill: () => void; - firstHiddenTime: number; -} - -declare global { - interface Window { - webVitals: WebVitalsGlobal; - - // Build flags: - __WEB_VITALS_POLYFILL__: boolean; - } -} - // -------------------------------------------------------------------------- // Everything below is modifications to built-in modules. // -------------------------------------------------------------------------- From be20a19d6a0abee9f63ac8ac5970237dab084051 Mon Sep 17 00:00:00 2001 From: Philip Walton Date: Fri, 29 Mar 2024 10:28:06 -0700 Subject: [PATCH 03/33] Add INP breakdown timings and LoAF attribution (#442) * Remove missed polyfill code * Remove unused rollup plugin * Add frame-based INP and LoAF attribution * Add additional code comments * Update src/types.ts Co-authored-by: Barry Pollard * Update src/onINP.ts Co-authored-by: Barry Pollard * Address review feedback * Update README to match latest JSDocs * Apply suggestions from code review Co-authored-by: Barry Pollard * Address review feedback * Add fallback for requestIdleCallback * Add missing null check * Rename processingTime to processingDuration * Update tests to reduce flakiness * Address review feedback * Update type definitions and descriptions * Update src/onINP.ts Co-authored-by: Barry Pollard * Revert interactionType to be pointer or keyboard * Increase the past renderTimes frame length * Format code * Update README.md Co-authored-by: Brendan Kenny * Update README * Add test for LoAF entries * Fix failing test --------- Co-authored-by: Barry Pollard Co-authored-by: Brendan Kenny --- .eslintrc | 4 +- README.md | 100 +++++++-- src/attribution/onINP.ts | 258 ++++++++++++++++++--- src/lib/getNavigationEntry.ts | 2 +- src/lib/interactions.ts | 139 ++++++++++++ src/lib/observe.ts | 5 +- src/lib/onHidden.ts | 18 +- src/lib/runOnce.ts | 10 +- src/lib/whenIdle.ts | 38 ++++ src/onINP.ts | 147 ++---------- src/onLCP.ts | 3 +- src/types.ts | 8 +- src/types/inp.ts | 90 ++++++-- test/e2e/onFCP-test.js | 18 +- test/e2e/onINP-test.js | 350 +++++++++++++++++++---------- test/e2e/onLCP-test.js | 41 ++-- test/e2e/onTTFB-test.js | 1 - test/server.js | 1 + test/utils/browserSupportsEntry.js | 8 +- test/utils/nextFrame.js | 6 +- test/views/cls.njk | 31 +-- test/views/fcp.njk | 2 +- test/views/fid.njk | 2 +- test/views/inp.njk | 13 +- test/views/layout.njk | 25 ++- test/views/lcp.njk | 17 +- test/views/ttfb.njk | 18 +- 27 files changed, 901 insertions(+), 454 deletions(-) create mode 100644 src/lib/interactions.ts create mode 100644 src/lib/whenIdle.ts diff --git a/.eslintrc b/.eslintrc index 1ab87ef0..d5d6bfd5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,7 +21,8 @@ "files": ["test/e2e/*.js"], "globals": { "$": false, - "browser": false + "browser": false, + "__toSafeObject": false }, "extends": ["eslint:recommended"], "rules": { @@ -63,6 +64,7 @@ "node/no-missing-require": "off", "node/shebang": "off", "no-dupe-class-members": "off", + "prefer-spread": "off", "space-before-function-paren": [ "error", { diff --git a/README.md b/README.md index 5f01f854..2ee9f85b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ## Overview -The `web-vitals` library is a tiny (~1.5K, brotli'd), modular library for measuring all the [Web Vitals](https://web.dev/articles/vitals) metrics on real users, in a way that accurately matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console's Speed Report](https://webmasters.googleblog.com/2019/11/search-console-speed-report.html)). +The `web-vitals` library is a tiny (~2K, brotli'd), modular library for measuring all the [Web Vitals](https://web.dev/articles/vitals) metrics on real users, in a way that accurately matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console's Speed Report](https://webmasters.googleblog.com/2019/11/search-console-speed-report.html)). The library supports all of the [Core Web Vitals](https://web.dev/articles/vitals#core_web_vitals) as well as a number of other metrics that are useful in diagnosing [real-user](https://web.dev/articles/user-centric-performance-metrics) performance issues. @@ -442,14 +442,14 @@ The following table lists all the builds distributed with the `web-vitals` packa web-vitals.umd.cjs pkg.main - A UMD version of the web-vitals.js bundle (exposed on the window.webVitals.* namespace). + A UMD version of the web-vitals.js bundle (exposed on the self.webVitals.* namespace). web-vitals.iife.js -- - An IIFE version of the web-vitals.js bundle (exposed on the window.webVitals.* namespace). + An IIFE version of the web-vitals.js bundle (exposed on the self.webVitals.* namespace). @@ -463,7 +463,7 @@ The following table lists all the builds distributed with the `web-vitals` packa web-vitals.attribution.umd.cjs -- - A UMD version of the web-vitals.attribution.js build (exposed on the window.webVitals.* namespace). + A UMD version of the web-vitals.attribution.js build (exposed on the self.webVitals.* namespace). @@ -471,7 +471,7 @@ The following table lists all the builds distributed with the `web-vitals` packa web-vitals.attribution.iife.js -- - An IIFE version of the web-vitals.attribution.js build (exposed on the window.webVitals.* namespace). + An IIFE version of the web-vitals.attribution.js build (exposed on the self.webVitals.* namespace). @@ -877,31 +877,89 @@ interface FIDAttribution { ```ts interface INPAttribution { /** - * A selector identifying the element that the user interacted with for - * the event corresponding to INP. This element will be the `target` of the - * `event` dispatched. + * A selector identifying the element that the user first interacted with + * as part of the frame where the INP candidate interaction occurred. + * If `interactionTarget` is an empty string, that generally means the + * element was removed from the DOM after the interaction. */ - eventTarget?: string; + interactionTarget: string; + /** - * The time when the user interacted for the event corresponding to INP. - * This time will match the `timeStamp` value of the `event` dispatched. + * The time when the user first interacted during the frame where the INP + * candidate interaction occurred (if more than one interaction occurred + * within the frame, only the first time is reported). */ - eventTime?: number; + interactionTime: DOMHighResTimeStamp; + /** - * The `type` of the `event` dispatched corresponding to INP. + * The best-guess timestamp of the next paint after the interaction. + * In general, this timestamp is the same as the `startTime + duration` of + * the event timing entry. However, since `duration` values are rounded to + * the nearest 8ms, it can sometimes appear that the paint occurred before + * processing ended (which cannot happen). This value clamps the paint time + * so it's always after `processingEnd` from the Event Timing API and + * `renderStart` from the Long Animation Frame API (where available). + * It also averages the duration values for all entries in the same + * animation frame, which should be closer to the "real" value. */ - eventType?: string; + nextPaintTime: DOMHighResTimeStamp; + /** - * The `PerformanceEventTiming` entry corresponding to INP. + * The type of interaction, based on the event type of the `event` entry + * that corresponds to the interaction (i.e. the first `event` entry + * containing an `interactionId` dispatched in a given animation frame). + * For "pointerdown", "pointerup", or "click" events this will be "pointer", + * and for "keydown" or "keyup" events this will be "keyboard". */ - eventEntry?: PerformanceEventTiming; + interactionType: 'pointer' | 'keyboard'; + /** - * The loading state of the document at the time when the even corresponding - * to INP occurred (see `LoadState` for details). If the interaction occurred - * while the document was loading and executing script (e.g. usually in the - * `dom-interactive` phase) it can result in long delays. + * An array of Event Timing entries that were processed within the same + * animation frame as the INP candidate interaction. */ - loadState?: LoadState; + processedEventEntries: PerformanceEventTiming[]; + + /** + * If the browser supports the Long Animation Frame API, this array will + * include any `long-animation-frame` entries that intersect with the INP + * candidate interaction's `startTime` and the `processingEnd` time of the + * last event processed within that animation frame. If the browser does not + * support the Long Animation Frame API or no `long-animation-frame` entries + * are detect, this array will be empty. + */ + longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + + /** + * The time from when the user interacted with the page until when the + * browser was first able to start processing event listeners for that + * interaction. This time captures the delay before event processing can + * begin due to the main thread being busy with other work. + */ + inputDelay: number; + + /** + * The time from when the first event listener started running in response to + * the user interaction until when all event listener processing has finished. + */ + processingDuration: number; + + /** + * The time from when the browser finished processing all event listeners for + * the user interaction until the next frame is presented on the screen and + * visible to the user. This time includes work on the main thread (such as + * `requestAnimationFrame()` callbacks, `ResizeObserver` and + * `IntersectionObserver` callbacks, and style/layout calculation) as well + * as off-main-thread work (such as compositor, GPU, and raster work). + */ + presentationDelay: number; + + /** + * The loading state of the document at the time when the interaction + * corresponding to INP occurred (see `LoadState` for details). If the + * interaction occurred while the document was loading and executing script + * (e.g. usually in the `dom-interactive` phase) it can result in long delays. + */ + loadState: LoadState; } ``` diff --git a/src/attribution/onINP.ts b/src/attribution/onINP.ts index 94c877ca..3b6920c2 100644 --- a/src/attribution/onINP.ts +++ b/src/attribution/onINP.ts @@ -16,6 +16,12 @@ import {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; +import { + longestInteractionList, + entryPreProcessingCallbacks, +} from '../lib/interactions.js'; +import {observe} from '../lib/observe.js'; +import {whenIdle} from '../lib/whenIdle.js'; import {onINP as unattributedOnINP} from '../onINP.js'; import { INPMetric, @@ -25,38 +31,215 @@ import { ReportOpts, } from '../types.js'; -const attributeINP = (metric: INPMetric): void => { - if (metric.entries.length) { - const longestEntry = metric.entries.sort((a, b) => { - // Sort by: 1) duration (DESC), then 2) processing time (DESC) - return ( - b.duration - a.duration || - b.processingEnd - - b.processingStart - - (a.processingEnd - a.processingStart) +interface pendingEntriesGroup { + startTime: DOMHighResTimeStamp; + processingStart: DOMHighResTimeStamp; + processingEnd: DOMHighResTimeStamp; + entries: PerformanceEventTiming[]; +} + +// A PerformanceObserver, observing new `long-animation-frame` entries. +// If this variable is defined it means the browser supports LoAF. +let loafObserver: PerformanceObserver | undefined; + +// A list of LoAF entries that have been dispatched and could potentially +// intersect with the INP candidate interaction. Note that periodically this +// list is cleaned up and entries that are known to not match INP are removed. +let pendingLoAFs: PerformanceLongAnimationFrameTiming[] = []; + +// A mapping between a particular frame's render time and all of the +// event timing entries that occurred within that frame. Note that periodically +// this map is cleaned up and entries that are known to not match INP are +// removed. +const pendingEntriesGroupMap: Map = new Map(); + +// A list of recent render times. This corresponds to the keys in +// `pendingEntriesGroupMap` but as an array so it can be iterated on in +// reverse. Note that this list is periodically clean up and old render times +// are removed. +let previousRenderTimes: number[] = []; + +// A WeakMap so you can look up the `renderTime` of a given entry and the +// value returned will be the same value used by `pendingEntriesGroupMap`. +const entryToRenderTimeMap: WeakMap< + PerformanceEventTiming, + DOMHighResTimeStamp +> = new WeakMap(); + +// A reference to the idle task used to clean up entries from the above +// variables. If the value is -1 it means no task is queue, and if it's +// greater than -1 the value corresponds to the idle callback handle. +let idleHandle: number = -1; + +/** + * Adds new LoAF entries to the `pendingLoAFs` list. + */ +const handleLoAFEntries = (entries: PerformanceLongAnimationFrameTiming[]) => { + entries.forEach((entry) => pendingLoAFs.push(entry)); +}; + +/** + * Groups entries that were presented within the same animation frame by + * a common `renderTime`. This function works by referencing + * `pendingEntriesGroupMap` and using an existing render time if one is found + * (otherwise creating a new one). This function also adds all interaction + * entries to an `entryToRenderTimeMap` WeakMap so that the "grouped" render + * times can be looked up later. + */ +const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => { + let renderTime = entry.startTime + entry.duration; + let previousRenderTime; + + // Iterate of all previous render times in reverse order to find a match. + // Go in reverse since the most likely match will be at the end. + for (let i = previousRenderTimes.length - 1; i >= 0; i--) { + previousRenderTime = previousRenderTimes[i]; + + // If a previous render time is within 8ms of the current render time, + // assume they were part of the same frame and re-use the previous time. + // Also break out of the loop because all subsequent times will be newer. + if (Math.abs(renderTime - previousRenderTime) <= 8) { + const group = pendingEntriesGroupMap.get(previousRenderTime)!; + group.startTime = Math.min(entry.startTime, group.startTime); + group.processingStart = Math.min( + entry.processingStart, + group.processingStart, ); - })[0]; - - // Currently Chrome can return a null target for certain event types - // (especially pointer events). As the event target should be the same - // for all events in the same interaction, we pick the first non-null one. - // TODO: remove when 1367329 is resolved - // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329 - const firstEntryWithTarget = metric.entries.find((entry) => entry.target); - - (metric as INPMetricWithAttribution).attribution = { - eventTarget: getSelector( - firstEntryWithTarget && firstEntryWithTarget.target, - ), - eventType: longestEntry.name, - eventTime: longestEntry.startTime, - eventEntry: longestEntry, - loadState: getLoadState(longestEntry.startTime), - }; - return; + group.processingEnd = Math.max(entry.processingEnd, group.processingEnd); + group.entries.push(entry); + + renderTime = previousRenderTime; + break; + } + } + + // If there was no matching render time, assume this is a new frame. + if (renderTime !== previousRenderTime) { + previousRenderTimes.push(renderTime); + pendingEntriesGroupMap.set(renderTime, { + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + entries: [entry], + }); + } + + // Store the grouped render time for this entry for reference later. + if (entry.interactionId || entry.entryType === 'first-input') { + entryToRenderTimeMap.set(entry, renderTime); + } + + // Queue cleanup of entries that are not part of any INP candidates. + if (idleHandle < 0) { + idleHandle = whenIdle(cleanupEntries); } - // Set an empty object if no other attribution has been set. - (metric as INPMetricWithAttribution).attribution = {}; +}; + +const cleanupEntries = () => { + // The list of previous render times is used to handle cases where + // events are dispatched out of order. When this happens they're generally + // only off by a frame or two, so keeping the most recent 50 should be + // more than sufficient. + previousRenderTimes = previousRenderTimes.slice(-50); + + // Keep all render times that are part of a pending INP candidate or + // that occurred within the 50 most recently-dispatched animation frames. + const renderTimesToKeep = new Set( + (previousRenderTimes as (number | undefined)[]).concat( + longestInteractionList.map((i) => entryToRenderTimeMap.get(i.entries[0])), + ), + ); + + pendingEntriesGroupMap.forEach((_, key) => { + if (!renderTimesToKeep.has(key)) pendingEntriesGroupMap.delete(key); + }); + + // Remove all pending LoAF entries that don't intersect with entries in + // the newly cleaned up `pendingEntriesGroupMap`. + const loafsToKeep: Set = new Set(); + pendingEntriesGroupMap.forEach((group) => { + getIntersectingLoAFs(group.startTime, group.processingEnd).forEach( + (loaf) => { + loafsToKeep.add(loaf); + }, + ); + }); + pendingLoAFs = Array.from(loafsToKeep); + + // Reset the idle callback handle so it can be queued again. + idleHandle = -1; +}; + +entryPreProcessingCallbacks.push(groupEntriesByRenderTime); + +const getIntersectingLoAFs = ( + start: DOMHighResTimeStamp, + end: DOMHighResTimeStamp, +) => { + const intersectingLoAFs = []; + + for (let i = 0, loaf; (loaf = pendingLoAFs[i]); i++) { + // If the LoAF ends before the given start time, ignore it. + if (loaf.startTime + loaf.duration < start) continue; + + // If the LoAF starts after the given end time, ignore it and all + // subsequent pending LoAFs (because they're in time order). + if (loaf.startTime > end) break; + + // Still here? If so this LoAF intersects with the interaction. + intersectingLoAFs.push(loaf); + } + return intersectingLoAFs; +}; + +const attributeINP = (metric: INPMetric): void => { + const firstEntry = metric.entries[0]; + const renderTime = entryToRenderTimeMap.get(firstEntry)!; + const group = pendingEntriesGroupMap.get(renderTime)!; + + const processingStart = firstEntry.processingStart; + const processingEnd = group.processingEnd; + + // Sort the entries in processing time order. + const processedEventEntries = group.entries.sort((a, b) => { + return a.processingStart - b.processingStart; + }); + + const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] = + getIntersectingLoAFs(firstEntry.startTime, processingEnd); + + // The first interaction entry may not have a target defined, so use the + // first one found in the entry list. + // TODO: when the following bug is fixed just use `firstInteractionEntry`. + // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329 + const firstEntryWithTarget = metric.entries.find((entry) => entry.target); + + // Since entry durations are rounded to the nearest 8ms, we need to clamp + // the `nextPaintTime` value to be higher than the `processingEnd` or + // end time of any LoAF entry. + const nextPaintTimeCandidates = [ + firstEntry.startTime + firstEntry.duration, + processingEnd, + ].concat( + longAnimationFrameEntries.map((loaf) => loaf.startTime + loaf.duration), + ); + + const nextPaintTime = Math.max.apply(Math, nextPaintTimeCandidates); + + (metric as INPMetricWithAttribution).attribution = { + interactionTarget: getSelector( + firstEntryWithTarget && firstEntryWithTarget.target, + ), + interactionType: firstEntry.name.startsWith('key') ? 'keyboard' : 'pointer', + interactionTime: firstEntry.startTime, + nextPaintTime: nextPaintTime, + processedEventEntries: processedEventEntries, + longAnimationFrameEntries: longAnimationFrameEntries, + inputDelay: processingStart - firstEntry.startTime, + processingDuration: processingEnd - processingStart, + presentationDelay: Math.max(nextPaintTime - processingEnd, 0), + loadState: getLoadState(firstEntry.startTime), + }; }; /** @@ -90,10 +273,21 @@ export const onINP = ( onReport: INPReportCallbackWithAttribution, opts?: ReportOpts, ) => { + if (!loafObserver) { + loafObserver = observe('long-animation-frame', handleLoAFEntries); + } unattributedOnINP( ((metric: INPMetricWithAttribution) => { - attributeINP(metric); - onReport(metric); + // Queue attribution and reporting in the next idle task. + // This is needed to increase the chances that all event entries that + // occurred between the user interaction and the next paint + // have been dispatched. Note: there is currently an experiment + // running in Chrome (EventTimingKeypressAndCompositionInteractionId) + // 123+ that if rolled out fully would make this no longer necessary. + whenIdle(() => { + attributeINP(metric); + onReport(metric); + }); }) as INPReportCallback, opts, ); diff --git a/src/lib/getNavigationEntry.ts b/src/lib/getNavigationEntry.ts index 3a3e8fd3..e575c584 100644 --- a/src/lib/getNavigationEntry.ts +++ b/src/lib/getNavigationEntry.ts @@ -18,7 +18,7 @@ export const getNavigationEntry = (): | PerformanceNavigationTiming | undefined => { return ( - window.performance && + self.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0] ); diff --git a/src/lib/interactions.ts b/src/lib/interactions.ts new file mode 100644 index 00000000..fecf36df --- /dev/null +++ b/src/lib/interactions.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {getInteractionCount} from './polyfills/interactionCountPolyfill.js'; + +interface Interaction { + id: number; + latency: number; + entries: PerformanceEventTiming[]; +} + +interface EntryPreProcessingHook { + (entry: PerformanceEventTiming): void; +} + +// A list of longest interactions on the page (by latency) sorted so the +// longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER long. +export const longestInteractionList: Interaction[] = []; + +// A mapping of longest interactions by their interaction ID. +// This is used for faster lookup. +export const longestInteractionMap: Map = new Map(); + +// The default `durationThreshold` used across this library for observing +// `event` entries via PerformanceObserver. +export const DEFAULT_DURATION_THRESHOLD = 40; + +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +export const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; +}; + +export const resetInteractions = () => { + prevInteractionCount = 0; + longestInteractionList.length = 0; + longestInteractionMap.clear(); +}; + +/** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ +export const estimateP98LongestInteraction = () => { + const candidateInteractionIndex = Math.min( + longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return longestInteractionList[candidateInteractionIndex]; +}; + +// To prevent unnecessary memory usage on pages with lots of interactions, +// store at most 10 of the longest interactions to consider as INP candidates. +const MAX_INTERACTIONS_TO_CONSIDER = 10; + +/** + * A list of callback functions to run before each entry is processed. + * Exposing this list allows the attribution build to hook into the + * entry processing pipeline. + */ +export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; + +/** + * Takes a performance entry and adds it to the list of worst interactions + * if its duration is long enough to make it among the worst. If the + * entry is part of an existing interaction, it is merged and the latency + * and entries list is updated as needed. + */ +export const processInteractionEntry = (entry: PerformanceEventTiming) => { + entryPreProcessingCallbacks.forEach((cb) => cb(entry)); + + // Skip further processing for entries that cannot be INP candidates. + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + // The least-long of the 10 longest interactions. + const minLongestInteraction = + longestInteractionList[longestInteractionList.length - 1]; + + const existingInteraction = longestInteractionMap.get(entry.interactionId!); + + // Only process the entry if it's possibly one of the ten longest, + // or if it's part of an existing interaction. + if ( + existingInteraction || + longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || + entry.duration > minLongestInteraction.latency + ) { + // If the interaction already exists, update it. Otherwise create one. + if (existingInteraction) { + // If the new entry has a longer duration, replace the old entries, + // otherwise add to the array. + if (entry.duration > existingInteraction.latency) { + existingInteraction.entries = [entry]; + existingInteraction.latency = entry.duration; + } else if ( + entry.duration === existingInteraction.latency && + entry.startTime === existingInteraction.entries[0].startTime + ) { + existingInteraction.entries.push(entry); + } + } else { + const interaction = { + id: entry.interactionId!, + latency: entry.duration, + entries: [entry], + }; + longestInteractionMap.set(interaction.id, interaction); + longestInteractionList.push(interaction); + } + + // Sort the entries by latency (descending) and keep only the top ten. + longestInteractionList.sort((a, b) => b.latency - a.latency); + if (longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { + longestInteractionList + .splice(MAX_INTERACTIONS_TO_CONSIDER) + .forEach((i) => longestInteractionMap.delete(i.id)); + } + } +}; diff --git a/src/lib/observe.ts b/src/lib/observe.ts index af63f068..f3127a3d 100644 --- a/src/lib/observe.ts +++ b/src/lib/observe.ts @@ -16,10 +16,11 @@ interface PerformanceEntryMap { 'event': PerformanceEventTiming[]; - 'paint': PerformancePaintTiming[]; + 'first-input': PerformanceEventTiming[]; 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; - 'first-input': PerformanceEventTiming[]; + 'long-animation-frame': PerformanceLongAnimationFrameTiming[]; + 'paint': PerformancePaintTiming[]; 'navigation': PerformanceNavigationTiming[]; 'resource': PerformanceResourceTiming[]; } diff --git a/src/lib/onHidden.ts b/src/lib/onHidden.ts index a0f2b95f..f59d4c90 100644 --- a/src/lib/onHidden.ts +++ b/src/lib/onHidden.ts @@ -14,18 +14,10 @@ * limitations under the License. */ -export interface OnHiddenCallback { - (event: Event): void; -} - -export const onHidden = (cb: OnHiddenCallback) => { - const onHiddenOrPageHide = (event: Event) => { - if (event.type === 'pagehide' || document.visibilityState === 'hidden') { - cb(event); +export const onHidden = (cb: () => void) => { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + cb(); } - }; - addEventListener('visibilitychange', onHiddenOrPageHide, true); - // Some browsers have buggy implementations of visibilitychange, - // so we use pagehide in addition, just to be safe. - addEventListener('pagehide', onHiddenOrPageHide, true); + }); }; diff --git a/src/lib/runOnce.ts b/src/lib/runOnce.ts index c232fa16..f2de2ead 100644 --- a/src/lib/runOnce.ts +++ b/src/lib/runOnce.ts @@ -14,15 +14,11 @@ * limitations under the License. */ -export interface RunOnceCallback { - (arg: unknown): void; -} - -export const runOnce = (cb: RunOnceCallback) => { +export const runOnce = (cb: () => void) => { let called = false; - return (arg: unknown) => { + return () => { if (!called) { - cb(arg); + cb(); called = true; } }; diff --git a/src/lib/whenIdle.ts b/src/lib/whenIdle.ts new file mode 100644 index 00000000..b1ab7227 --- /dev/null +++ b/src/lib/whenIdle.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {onHidden} from './onHidden.js'; + +const rIC = self.requestIdleCallback || self.setTimeout; +const cIC = self.cancelIdleCallback || self.clearTimeout; + +/** + * Runs the passed callback during the next idle period, or immediately + * if the browser's visibility state is (or becomes) hidden. + */ +export const whenIdle = (cb: () => void): number => { + let handle = -1; + if (document.visibilityState === 'hidden') { + cb(); + } else { + handle = rIC(cb); + onHidden(() => { + cIC(handle); + cb(); + }); + } + return handle; +}; diff --git a/src/onINP.ts b/src/onINP.ts index 73a46503..6cf6d551 100644 --- a/src/onINP.ts +++ b/src/onINP.ts @@ -17,13 +17,18 @@ import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; import {initMetric} from './lib/initMetric.js'; +import { + DEFAULT_DURATION_THRESHOLD, + processInteractionEntry, + estimateP98LongestInteraction, + getInteractionCountForNavigation, + resetInteractions, +} from './lib/interactions.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; -import { - getInteractionCount, - initInteractionCountPolyfill, -} from './lib/polyfills/interactionCountPolyfill.js'; +import {initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js'; import {whenActivated} from './lib/whenActivated.js'; + import { INPMetric, INPReportCallback, @@ -31,97 +36,9 @@ import { ReportOpts, } from './types.js'; -interface Interaction { - id: number; - latency: number; - entries: PerformanceEventTiming[]; -} - /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; -// Used to store the interaction count after a bfcache restore, since p98 -// interaction latencies should only consider the current navigation. -let prevInteractionCount = 0; - -/** - * Returns the interaction count since the last bfcache restore (or for the - * full page lifecycle if there were no bfcache restores). - */ -const getInteractionCountForNavigation = () => { - return getInteractionCount() - prevInteractionCount; -}; - -// To prevent unnecessary memory usage on pages with lots of interactions, -// store at most 10 of the longest interactions to consider as INP candidates. -const MAX_INTERACTIONS_TO_CONSIDER = 10; - -// A list of longest interactions on the page (by latency) sorted so the -// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long. -let longestInteractionList: Interaction[] = []; - -// A mapping of longest interactions by their interaction ID. -// This is used for faster lookup. -const longestInteractionMap: {[interactionId: string]: Interaction} = {}; - -/** - * Takes a performance entry and adds it to the list of worst interactions - * if its duration is long enough to make it among the worst. If the - * entry is part of an existing interaction, it is merged and the latency - * and entries list is updated as needed. - */ -const processEntry = (entry: PerformanceEventTiming) => { - // The least-long of the 10 longest interactions. - const minLongestInteraction = - longestInteractionList[longestInteractionList.length - 1]; - - const existingInteraction = longestInteractionMap[entry.interactionId!]; - - // Only process the entry if it's possibly one of the ten longest, - // or if it's part of an existing interaction. - if ( - existingInteraction || - longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || - entry.duration > minLongestInteraction.latency - ) { - // If the interaction already exists, update it. Otherwise create one. - if (existingInteraction) { - existingInteraction.entries.push(entry); - existingInteraction.latency = Math.max( - existingInteraction.latency, - entry.duration, - ); - } else { - const interaction = { - id: entry.interactionId!, - latency: entry.duration, - entries: [entry], - }; - longestInteractionMap[interaction.id] = interaction; - longestInteractionList.push(interaction); - } - - // Sort the entries by latency (descending) and keep only the top ten. - longestInteractionList.sort((a, b) => b.latency - a.latency); - longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach((i) => { - delete longestInteractionMap[i.id]; - }); - } -}; - -/** - * Returns the estimated p98 longest interaction based on the stored - * interaction candidates and the interaction count for the current page. - */ -const estimateP98LongestInteraction = () => { - const candidateInteractionIndex = Math.min( - longestInteractionList.length - 1, - Math.floor(getInteractionCountForNavigation() / 50), - ); - - return longestInteractionList[candidateInteractionIndex]; -}; - /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with @@ -160,34 +77,21 @@ export const onINP = (onReport: INPReportCallback, opts?: ReportOpts) => { let metric = initMetric('INP'); let report: ReturnType; + const durationThreshold = + opts!.durationThreshold ?? DEFAULT_DURATION_THRESHOLD; + const handleEntries = (entries: INPMetric['entries']) => { entries.forEach((entry) => { - if (entry.interactionId) { - processEntry(entry); - } - - // Entries of type `first-input` don't currently have an `interactionId`, - // so to consider them in INP we have to first check that an existing - // entry doesn't match the `duration` and `startTime`. - // Note that this logic assumes that `event` entries are dispatched - // before `first-input` entries. This is true in Chrome (the only browser - // that currently supports INP). - // TODO(philipwalton): remove once crbug.com/1325826 is fixed. - if (entry.entryType === 'first-input') { - const noMatchingEntry = !longestInteractionList.some( - (interaction) => { - return interaction.entries.some((prevEntry) => { - return ( - entry.duration === prevEntry.duration && - entry.startTime === prevEntry.startTime - ); - }); - }, - ); - if (noMatchingEntry) { - processEntry(entry); - } + // Ignore `first-input` entries if the duration value is greater + // than the threshold (since that means an `event` entry should + // have been dispatched). + if ( + entry.entryType === 'first-input' && + entry.duration >= durationThreshold + ) { + return; } + processInteractionEntry(entry); }); const inp = estimateP98LongestInteraction(); @@ -206,7 +110,7 @@ export const onINP = (onReport: INPReportCallback, opts?: ReportOpts) => { // and performance. Running this callback for any interaction that spans // just one or two frames is likely not worth the insight that could be // gained. - durationThreshold: opts!.durationThreshold ?? 40, + durationThreshold: opts!.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, } as PerformanceObserverInit); report = bindReporter( @@ -221,7 +125,7 @@ export const onINP = (onReport: INPReportCallback, opts?: ReportOpts) => { // observe entries of type `first-input`. This is useful in cases // where the first interaction is less than the `durationThreshold`. if ( - 'PerformanceEventTiming' in window && + 'PerformanceEventTiming' in self && 'interactionId' in PerformanceEventTiming.prototype ) { po.observe({type: 'first-input', buffered: true}); @@ -243,10 +147,7 @@ export const onINP = (onReport: INPReportCallback, opts?: ReportOpts) => { // Only report after a bfcache restore if the `PerformanceObserver` // successfully registered. onBFCacheRestore(() => { - longestInteractionList = []; - // Important, we want the count for the full page here, - // not just for the current navigation. - prevInteractionCount = getInteractionCount(); + resetInteractions(); metric = initMetric('INP'); report = bindReporter( diff --git a/src/onLCP.ts b/src/onLCP.ts index 6e6da1c0..622c3638 100644 --- a/src/onLCP.ts +++ b/src/onLCP.ts @@ -24,6 +24,7 @@ import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; import {runOnce} from './lib/runOnce.js'; import {whenActivated} from './lib/whenActivated.js'; +import {whenIdle} from './lib/whenIdle.js'; import { LCPMetric, MetricRatingThresholds, @@ -103,7 +104,7 @@ export const onLCP = (onReport: LCPReportCallback, opts?: ReportOpts) => { // Wrap in a setTimeout so the callback is run in a separate task // to avoid extending the keyboard/click handler to reduce INP impact // https://github.com/GoogleChrome/web-vitals/issues/383 - addEventListener(type, () => setTimeout(stopListening, 0), true); + addEventListener(type, () => whenIdle(stopListening), true); }); onHidden(stopListening); diff --git a/src/types.ts b/src/types.ts index 9246841e..fd7dc503 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,7 +62,7 @@ declare global { // https://wicg.github.io/event-timing/#sec-performance-event-timing interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; - interactionId?: number; + interactionId: number; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution @@ -88,4 +88,10 @@ declare global { url: string; element?: Element; } + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { + renderStart: DOMHighResTimeStamp; + duration: DOMHighResTimeStamp; + } } diff --git a/src/types/inp.ts b/src/types/inp.ts index fd923b60..039ffb26 100644 --- a/src/types/inp.ts +++ b/src/types/inp.ts @@ -31,31 +31,89 @@ export interface INPMetric extends Metric { */ export interface INPAttribution { /** - * A selector identifying the element that the user interacted with for - * the event corresponding to INP. This element will be the `target` of the - * `event` dispatched. + * A selector identifying the element that the user first interacted with + * as part of the frame where the INP candidate interaction occurred. + * If `interactionTarget` is an empty string, that generally means the + * element was removed from the DOM after the interaction. */ - eventTarget?: string; + interactionTarget: string; + + /** + * The time when the user first interacted during the frame where the INP + * candidate interaction occurred (if more than one interaction occurred + * within the frame, only the first time is reported). + */ + interactionTime: DOMHighResTimeStamp; + + /** + * The best-guess timestamp of the next paint after the interaction. + * In general, this timestamp is the same as the `startTime + duration` of + * the event timing entry. However, since `duration` values are rounded to + * the nearest 8ms, it can sometimes appear that the paint occurred before + * processing ended (which cannot happen). This value clamps the paint time + * so it's always after `processingEnd` from the Event Timing API and + * `renderStart` from the Long Animation Frame API (where available). + * It also averages the duration values for all entries in the same + * animation frame, which should be closer to the "real" value. + */ + nextPaintTime: DOMHighResTimeStamp; + /** - * The time when the user interacted for the event corresponding to INP. - * This time will match the `timeStamp` value of the `event` dispatched. + * The type of interaction, based on the event type of the `event` entry + * that corresponds to the interaction (i.e. the first `event` entry + * containing an `interactionId` dispatched in a given animation frame). + * For "pointerdown", "pointerup", or "click" events this will be "pointer", + * and for "keydown" or "keyup" events this will be "keyboard". */ - eventTime?: number; + interactionType: 'pointer' | 'keyboard'; + /** - * The `type` of the `event` dispatched corresponding to INP. + * An array of Event Timing entries that were processed within the same + * animation frame as the INP candidate interaction. */ - eventType?: string; + processedEventEntries: PerformanceEventTiming[]; + /** - * The `PerformanceEventTiming` entry corresponding to INP. + * If the browser supports the Long Animation Frame API, this array will + * include any `long-animation-frame` entries that intersect with the INP + * candidate interaction's `startTime` and the `processingEnd` time of the + * last event processed within that animation frame. If the browser does not + * support the Long Animation Frame API or no `long-animation-frame` entries + * are detect, this array will be empty. */ - eventEntry?: PerformanceEventTiming; + longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + + /** + * The time from when the user interacted with the page until when the + * browser was first able to start processing event listeners for that + * interaction. This time captures the delay before event processing can + * begin due to the main thread being busy with other work. + */ + inputDelay: number; + + /** + * The time from when the first event listener started running in response to + * the user interaction until when all event listener processing has finished. + */ + processingDuration: number; + + /** + * The time from when the browser finished processing all event listeners for + * the user interaction until the next frame is presented on the screen and + * visible to the user. This time includes work on the main thread (such as + * `requestAnimationFrame()` callbacks, `ResizeObserver` and + * `IntersectionObserver` callbacks, and style/layout calculation) as well + * as off-main-thread work (such as compositor, GPU, and raster work). + */ + presentationDelay: number; + /** - * The loading state of the document at the time when the event corresponding - * to INP occurred (see `LoadState` for details). If the interaction occurred - * while the document was loading and executing script (e.g. usually in the - * `dom-interactive` phase) it can result in long delays. + * The loading state of the document at the time when the interaction + * corresponding to INP occurred (see `LoadState` for details). If the + * interaction occurred while the document was loading and executing script + * (e.g. usually in the `dom-interactive` phase) it can result in long delays. */ - loadState?: LoadState; + loadState: LoadState; } /** diff --git a/test/e2e/onFCP-test.js b/test/e2e/onFCP-test.js index 95df599e..c2eb6245 100644 --- a/test/e2e/onFCP-test.js +++ b/test/e2e/onFCP-test.js @@ -325,17 +325,12 @@ describe('onFCP()', async function () { await beaconCountIs(1); const navEntry = await browser.execute(() => { - return performance.getEntriesByType('navigation')[0].toJSON(); + return __toSafeObject(performance.getEntriesByType('navigation')[0]); }); const fcpEntry = await browser.execute(() => { - return performance - .getEntriesByName('first-contentful-paint')[0] - .toJSON(); - }); - - // Since this value is stubbed in the browser, get it separately. - const activationStart = await browser.execute(() => { - return performance.getEntriesByType('navigation')[0].activationStart; + return __toSafeObject( + performance.getEntriesByName('first-contentful-paint')[0], + ); }); const [fcp] = await getBeacons(); @@ -349,11 +344,12 @@ describe('onFCP()', async function () { assert.equal( fcp.attribution.timeToFirstByte, - Math.max(0, navEntry.responseStart - activationStart), + Math.max(0, navEntry.responseStart - navEntry.activationStart), ); assert.equal( fcp.attribution.firstByteToFCP, - fcp.value - Math.max(0, navEntry.responseStart - activationStart), + fcp.value - + Math.max(0, navEntry.responseStart - navEntry.activationStart), ); assert.deepEqual(fcp.attribution.fcpEntry, fcpEntry); diff --git a/test/e2e/onINP-test.js b/test/e2e/onINP-test.js index 566d93a7..e15fc813 100644 --- a/test/e2e/onINP-test.js +++ b/test/e2e/onINP-test.js @@ -29,8 +29,10 @@ describe('onINP()', async function () { this.retries(2); let browserSupportsINP; + let browserSupportsLoAF; before(async function () { browserSupportsINP = await browserSupportsEntry('event'); + browserSupportsLoAF = await browserSupportsEntry('long-animation-frame'); }); beforeEach(async function () { @@ -41,10 +43,10 @@ describe('onINP()', async function () { it('reports the correct value on visibility hidden after interactions (reportAllChanges === false)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100'); + await navigateTo('/test/inp?click=100', {readyState: 'interactive'}); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await stubVisibilityChange('hidden'); @@ -57,18 +59,19 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports the correct value on visibility hidden after interactions (reportAllChanges === true)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100&reportAllChanges=1'); + await navigateTo('/test/inp?click=100&reportAllChanges=1', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await beaconCountIs(1); @@ -79,18 +82,22 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports the correct value when script is loaded late (reportAllChanges === false)', async function () { if (!browserSupportsINP) this.skip(); + // Don't await the `interactive` ready state because DCL is delayed until + // after user input. await navigateTo('/test/inp?click=100&loadAfterInput=1'); + // Wait until + await nextFrame(); + const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await stubVisibilityChange('hidden'); @@ -103,18 +110,22 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports the correct value when loaded late (reportAllChanges === true)', async function () { if (!browserSupportsINP) this.skip(); + // Don't await the `interactive` ready state because DCL is delayed until + // after user input. await navigateTo('/test/inp?click=100&reportAllChanges=1&loadAfterInput=1'); + // Wait until + await nextFrame(); + const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await beaconCountIs(1); @@ -125,20 +136,19 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports the correct value on page unload after interactions (reportAllChanges === false)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100'); + await navigateTo('/test/inp?click=100', {readyState: 'interactive'}); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); - await navigateTo('about:blank'); + await navigateTo('about:blank', {readyState: 'interactive'}); await beaconCountIs(1); @@ -149,18 +159,19 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports the correct value on page unload after interactions (reportAllChanges === true)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100&reportAllChanges=1'); + await navigateTo('/test/inp?click=100&reportAllChanges=1', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await navigateTo('about:blank'); @@ -173,24 +184,25 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.match(inp.navigationType, /navigate|reload/); }); it('reports approx p98 interaction when 50+ interactions (reportAllChanges === false)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=60&pointerdown=600'); + await navigateTo('/test/inp?click=60&pointerdown=600', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await setBlockingTime('pointerdown', 400); - await h1.click(); + await simulateUserLikeClick(h1); - await setBlockingTime('pointerdown', 200); - await h1.click(); + await setBlockingTime('pointerdown', 100); + await simulateUserLikeClick(h1); await setBlockingTime('pointerdown', 0); @@ -199,6 +211,7 @@ describe('onINP()', async function () { const [inp1] = await getBeacons(); assert(inp1.value >= 600); // Initial pointerdown blocking time. + assert(allEntriesPresentTogether(inp1.entries)); assert.strictEqual(inp1.rating, 'poor'); await clearBeacons(); @@ -206,7 +219,7 @@ describe('onINP()', async function () { let count = 3; while (count < 50) { - await h1.click(); + await h1.click(); // Use .click() because it's faster. count++; } @@ -214,15 +227,16 @@ describe('onINP()', async function () { await beaconCountIs(1); const [inp2] = await getBeacons(); - assert(inp2.value >= 400); // Initial pointerdown blocking time. + assert(inp2.value >= 400); // 2nd-highest pointerdown blocking time. assert(inp2.value < inp1.value); // Should have gone down. + assert(allEntriesPresentTogether(inp2.entries)); assert.strictEqual(inp2.rating, 'needs-improvement'); await clearBeacons(); await stubVisibilityChange('visible'); while (count < 100) { - await h1.click(); + await h1.click(); // Use .click() because it's faster. count++; } @@ -230,30 +244,33 @@ describe('onINP()', async function () { await beaconCountIs(1); const [inp3] = await getBeacons(); - assert(inp3.value >= 200); // 2nd-highest pointerdown blocking time. + assert(inp3.value >= 100); // 2nd-highest pointerdown blocking time. assert(inp3.value < inp2.value); // Should have gone down. - assert.strictEqual(inp3.rating, 'needs-improvement'); + assert(allEntriesPresentTogether(inp3.entries)); + assert.strictEqual(inp3.rating, 'good'); }); it('reports approx p98 interaction when 50+ interactions (reportAllChanges === true)', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=60&pointerdown=600&reportAllChanges=1'); + await navigateTo('/test/inp?click=60&pointerdown=600&reportAllChanges=1', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await setBlockingTime('pointerdown', 400); - await h1.click(); + await simulateUserLikeClick(h1); - await setBlockingTime('pointerdown', 200); - await h1.click(); + await setBlockingTime('pointerdown', 100); + await simulateUserLikeClick(h1); await setBlockingTime('pointerdown', 0); let count = 3; while (count < 100) { - await h1.click(); + await h1.click(); // Use .click() because it's faster. count++; } @@ -263,23 +280,25 @@ describe('onINP()', async function () { assert(inp1.value >= 600); // Initial pointerdown blocking time. assert(inp2.value >= 400); // Initial pointerdown blocking time. assert(inp2.value < inp1.value); // Should have gone down. - assert(inp3.value >= 200); // 2nd-highest pointerdown blocking time. + assert(inp3.value >= 100); // 2nd-highest pointerdown blocking time. assert(inp3.value < inp2.value); // Should have gone down. - + assert(allEntriesPresentTogether(inp1.entries)); + assert(allEntriesPresentTogether(inp2.entries)); + assert(allEntriesPresentTogether(inp3.entries)); assert.strictEqual(inp1.rating, 'poor'); assert.strictEqual(inp2.rating, 'needs-improvement'); - assert.strictEqual(inp3.rating, 'needs-improvement'); + assert.strictEqual(inp3.rating, 'good'); }); it('reports a new interaction after bfcache restore', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp'); + await navigateTo('/test/inp', {readyState: 'interactive'}); await setBlockingTime('click', 100); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); // Ensure the interaction completes. await nextFrame(); @@ -294,7 +313,7 @@ describe('onINP()', async function () { assert.strictEqual(inp1.value, inp1.delta); assert.strictEqual(inp1.rating, 'good'); assert(containsEntry(inp1.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp1.entries)); + assert(allEntriesPresentTogether(inp1.entries)); assert.match(inp1.navigationType, /navigate|reload/); await clearBeacons(); @@ -311,6 +330,7 @@ describe('onINP()', async function () { await beaconCountIs(1); const [inp2] = await getBeacons(); + assert(inp2.value >= 0); assert(inp2.id.match(/^v3-\d+-\d+$/)); assert(inp1.id !== inp2.id); @@ -318,8 +338,8 @@ describe('onINP()', async function () { assert.strictEqual(inp2.value, inp2.delta); assert.strictEqual(inp2.rating, 'good'); assert(containsEntry(inp2.entries, 'keydown', '#textarea')); - assert(interactionIDsMatch(inp2.entries)); - assert(inp2.entries[0].interactionId > inp1.entries[0].interactionId); + assert(allEntriesPresentTogether(inp1.entries)); + assert(inp2.entries[0].startTime > inp1.entries[0].startTime); assert.strictEqual(inp2.navigationType, 'back-forward-cache'); await stubForwardBack(); @@ -344,15 +364,15 @@ describe('onINP()', async function () { assert.strictEqual(inp3.value, inp3.delta); assert.strictEqual(inp3.rating, 'needs-improvement'); assert(containsEntry(inp3.entries, 'pointerdown', '#reset')); - assert(interactionIDsMatch(inp3.entries)); - assert(inp3.entries[0].interactionId > inp2.entries[0].interactionId); + assert(allEntriesPresentTogether(inp3.entries)); + assert(inp3.entries[0].startTime > inp2.entries[0].startTime); assert.strictEqual(inp3.navigationType, 'back-forward-cache'); }); it('does not report if there were no interactions', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp'); + await navigateTo('/test/inp', {readyState: 'interactive'}); await stubVisibilityChange('hidden'); @@ -366,10 +386,12 @@ describe('onINP()', async function () { it('reports prerender as nav type for prerender', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100&prerender=1'); + await navigateTo('/test/inp?click=100&prerender=1', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await stubVisibilityChange('hidden'); @@ -382,18 +404,19 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.strictEqual(inp.navigationType, 'prerender'); }); it('reports restore as nav type for wasDiscarded', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100&wasDiscarded=1'); + await navigateTo('/test/inp?click=100&wasDiscarded=1', { + readyState: 'interactive', + }); const h1 = await $('h1'); - await h1.click(); + await simulateUserLikeClick(h1); await stubVisibilityChange('hidden'); @@ -406,8 +429,7 @@ describe('onINP()', async function () { assert.strictEqual(inp.value, inp.delta); assert.strictEqual(inp.rating, 'good'); assert(containsEntry(inp.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp.entries)); - assert(inp.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp.entries)); assert.strictEqual(inp.navigationType, 'restore'); }); @@ -415,13 +437,12 @@ describe('onINP()', async function () { it('includes attribution data on the metric object', async function () { if (!browserSupportsINP) this.skip(); - await navigateTo('/test/inp?click=100&attribution=1'); + await navigateTo('/test/inp?click=100&attribution=1', { + readyState: 'complete', + }); const h1 = await $('h1'); - await h1.click(); - - // Ensure the interaction completes. - await nextFrame(); + await simulateUserLikeClick(h1); await stubVisibilityChange('hidden'); @@ -435,33 +456,68 @@ describe('onINP()', async function () { assert.strictEqual(inp1.value, inp1.delta); assert.strictEqual(inp1.rating, 'good'); assert(containsEntry(inp1.entries, 'click', 'h1')); - assert(interactionIDsMatch(inp1.entries)); - assert(inp1.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp1.entries)); assert.match(inp1.navigationType, /navigate|reload/); - const clickEntry = inp1.entries.find((e) => e.name === 'click'); - assert.equal(inp1.attribution.eventTarget, 'html>body>main>h1'); - assert.equal(inp1.attribution.eventType, clickEntry.name); - assert.equal(inp1.attribution.eventTime, clickEntry.startTime); + assert.equal(inp1.attribution.interactionTarget, 'html>body>main>h1'); + assert.equal(inp1.attribution.interactionType, 'pointer'); + assert.equal(inp1.attribution.interactionTime, inp1.entries[0].startTime); assert.equal(inp1.attribution.loadState, 'complete'); + assert(allEntriesPresentTogether(inp1.attribution.processedEventEntries)); + + // Assert that the reported `nextPaintTime` estimate is not more than 8ms + // different from `startTime+duration` in the Event Timing API. + assert( + inp1.attribution.nextPaintTime - + (inp1.entries[0].startTime + inp1.entries[0].duration) <= + 8, + ); + // Assert that `nextPaintTime` is after processing ends. + assert( + inp1.attribution.nextPaintTime >= + inp1.attribution.interactionTime + + (inp1.attribution.inputDelay + inp1.attribution.processingDuration), + ); + // Assert that the INP phase durations adds up to the total duration. + assert.equal( + inp1.attribution.nextPaintTime - inp1.attribution.interactionTime, + inp1.attribution.inputDelay + + inp1.attribution.processingDuration + + inp1.attribution.presentationDelay, + ); - // Deep equal won't work since some of the properties are removed before - // sending to /collect, so just compare some. - const eventEntry1 = inp1.attribution.eventEntry; - assert.equal(eventEntry1.startTime, clickEntry.startTime); - assert.equal(eventEntry1.duration, clickEntry.duration); - assert.equal(eventEntry1.name, clickEntry.name); - assert.equal(eventEntry1.processingStart, clickEntry.processingStart); + // Assert that the INP phases timestamps match the values in + // the `processedEventEntries` array. + const sortedEntries1 = inp1.attribution.processedEventEntries.sort( + (a, b) => { + return a.processingStart - b.processingStart; + }, + ); + assert.equal( + inp1.attribution.interactionTime + inp1.attribution.inputDelay, + sortedEntries1[0].processingStart, + ); + assert.equal( + inp1.attribution.interactionTime + + inp1.attribution.inputDelay + + inp1.attribution.processingDuration, + sortedEntries1.at(-1).processingEnd, + ); + assert.equal( + inp1.attribution.nextPaintTime - inp1.attribution.presentationDelay, + sortedEntries1.at(-1).processingEnd, + ); await clearBeacons(); await stubVisibilityChange('visible'); - await setBlockingTime('pointerup', 200); + await setBlockingTime('keydown', 300); - const reset = await $('#reset'); - await reset.click(); + const textarea = await $('#textarea'); + await textarea.click(); + await browser.keys(['x']); - // Ensure the interaction completes. + // Wait a bit to ensure the click event has time to dispatch. await nextFrame(); await stubVisibilityChange('hidden'); @@ -474,32 +530,71 @@ describe('onINP()', async function () { assert.strictEqual(inp2.name, 'INP'); assert.strictEqual(inp2.value, inp1.value + inp2.delta); assert.strictEqual(inp2.rating, 'needs-improvement'); - assert(containsEntry(inp2.entries, 'pointerup', '#reset')); - assert(interactionIDsMatch(inp2.entries)); - assert(inp2.entries[0].interactionId > 0); + assert(allEntriesPresentTogether(inp2.entries)); assert.match(inp2.navigationType, /navigate|reload/); - const pointerupEntry = inp2.entries.find((e) => e.name === 'pointerup'); - assert.equal(inp2.attribution.eventTarget, '#reset'); - assert.equal(inp2.attribution.eventType, pointerupEntry.name); - assert.equal(inp2.attribution.eventTime, pointerupEntry.startTime); + assert.equal(inp2.attribution.interactionTarget, '#textarea'); + assert.equal(inp2.attribution.interactionType, 'keyboard'); + assert.equal(inp2.attribution.interactionTime, inp2.entries[0].startTime); assert.equal(inp2.attribution.loadState, 'complete'); + assert(allEntriesPresentTogether(inp2.attribution.processedEventEntries)); + assert( + containsEntry( + inp2.attribution.processedEventEntries, + 'keydown', + '#textarea', + ), + ); + + // Assert that the reported `nextPaintTime` estimate is not more than 8ms + // different from `startTime+duration` in the Event Timing API. + assert( + inp2.attribution.nextPaintTime - + (inp2.entries[0].startTime + inp2.entries[0].duration) <= + 8, + ); + // Assert that `nextPaintTime` is after processing ends. + assert( + inp2.attribution.nextPaintTime >= + inp2.attribution.interactionTime + + (inp2.attribution.inputDelay + inp2.attribution.processingDuration), + ); + // Assert that the INP phase durations adds up to the total duration. + assert.equal( + inp2.attribution.nextPaintTime - inp2.attribution.interactionTime, + inp2.attribution.inputDelay + + inp2.attribution.processingDuration + + inp2.attribution.presentationDelay, + ); - // Deep equal won't work since some of the properties are removed before - // sending to /collect, so just compare some. - const eventEntry2 = inp2.attribution.eventEntry; - assert.equal(eventEntry2.startTime, pointerupEntry.startTime); - assert.equal(eventEntry2.duration, pointerupEntry.duration); - assert.equal(eventEntry2.name, pointerupEntry.name); - assert.equal(eventEntry2.processingStart, pointerupEntry.processingStart); + // Assert that the INP phases timestamps match the values in + // the `processedEventEntries` array. + const sortedEntries2 = inp2.attribution.processedEventEntries.sort( + (a, b) => { + return a.processingStart - b.processingStart; + }, + ); + assert.equal( + inp2.attribution.interactionTime + inp2.attribution.inputDelay, + sortedEntries2[0].processingStart, + ); + assert.equal( + inp2.attribution.interactionTime + + inp2.attribution.inputDelay + + inp2.attribution.processingDuration, + sortedEntries2.at(-1).processingEnd, + ); + assert.equal( + inp2.attribution.nextPaintTime - inp2.attribution.presentationDelay, + sortedEntries2.at(-1).processingEnd, + ); }); it('reports the domReadyState when input occurred', async function () { if (!browserSupportsINP) this.skip(); await navigateTo( - '/test/inp?' + - 'attribution=1&reportAllChanges=1&click=100&delayDCL=1000', + '/test/inp?attribution=1&reportAllChanges=1&click=100&delayDCL=1000', ); // Click on the

. @@ -512,11 +607,9 @@ describe('onINP()', async function () { const [inp1] = await getBeacons(); assert.equal(inp1.attribution.loadState, 'dom-interactive'); - await clearBeacons(); - await navigateTo( - '/test/inp?' + - 'attribution=1&reportAllChanges=1&click=100&delayResponse=1000', + '/test/inp' + + '?attribution=1&reportAllChanges=1&click=100&delayResponse=1000', ); // Click on the