Skip to content

Commit 61e9056

Browse files
authored
feat(web-vitals): Vendor in INP from web-vitals library (#9690)
vendored in from https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11 Note that this is on web vitals `v3.0.4`. We need to update to latest web vitals version - will do this in a follow up PR.
1 parent 28450ad commit 61e9056

File tree

3 files changed

+357
-0
lines changed

3 files changed

+357
-0
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
* Copyright 2022 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { bindReporter } from './lib/bindReporter';
18+
import { initMetric } from './lib/initMetric';
19+
import { observe } from './lib/observe';
20+
import { onHidden } from './lib/onHidden';
21+
import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill';
22+
import type { ReportCallback, ReportOpts } from './types';
23+
import type { INPMetric } from './types/inp';
24+
25+
interface Interaction {
26+
id: number;
27+
latency: number;
28+
entries: PerformanceEventTiming[];
29+
}
30+
31+
/**
32+
* Returns the interaction count since the last bfcache restore (or for the
33+
* full page lifecycle if there were no bfcache restores).
34+
*/
35+
const getInteractionCountForNavigation = (): number => {
36+
return getInteractionCount();
37+
};
38+
39+
// To prevent unnecessary memory usage on pages with lots of interactions,
40+
// store at most 10 of the longest interactions to consider as INP candidates.
41+
const MAX_INTERACTIONS_TO_CONSIDER = 10;
42+
43+
// A list of longest interactions on the page (by latency) sorted so the
44+
// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long.
45+
const longestInteractionList: Interaction[] = [];
46+
47+
// A mapping of longest interactions by their interaction ID.
48+
// This is used for faster lookup.
49+
const longestInteractionMap: { [interactionId: string]: Interaction } = {};
50+
51+
/**
52+
* Takes a performance entry and adds it to the list of worst interactions
53+
* if its duration is long enough to make it among the worst. If the
54+
* entry is part of an existing interaction, it is merged and the latency
55+
* and entries list is updated as needed.
56+
*/
57+
const processEntry = (entry: PerformanceEventTiming): void => {
58+
// The least-long of the 10 longest interactions.
59+
const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1];
60+
61+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
62+
const existingInteraction = longestInteractionMap[entry.interactionId!];
63+
64+
// Only process the entry if it's possibly one of the ten longest,
65+
// or if it's part of an existing interaction.
66+
if (
67+
existingInteraction ||
68+
longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER ||
69+
entry.duration > minLongestInteraction.latency
70+
) {
71+
// If the interaction already exists, update it. Otherwise create one.
72+
if (existingInteraction) {
73+
existingInteraction.entries.push(entry);
74+
existingInteraction.latency = Math.max(existingInteraction.latency, entry.duration);
75+
} else {
76+
const interaction = {
77+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
78+
id: entry.interactionId!,
79+
latency: entry.duration,
80+
entries: [entry],
81+
};
82+
longestInteractionMap[interaction.id] = interaction;
83+
longestInteractionList.push(interaction);
84+
}
85+
86+
// Sort the entries by latency (descending) and keep only the top ten.
87+
longestInteractionList.sort((a, b) => b.latency - a.latency);
88+
longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => {
89+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
90+
delete longestInteractionMap[i.id];
91+
});
92+
}
93+
};
94+
95+
/**
96+
* Returns the estimated p98 longest interaction based on the stored
97+
* interaction candidates and the interaction count for the current page.
98+
*/
99+
const estimateP98LongestInteraction = (): Interaction => {
100+
const candidateInteractionIndex = Math.min(
101+
longestInteractionList.length - 1,
102+
Math.floor(getInteractionCountForNavigation() / 50),
103+
);
104+
105+
return longestInteractionList[candidateInteractionIndex];
106+
};
107+
108+
/**
109+
* Calculates the [INP](https://web.dev/responsiveness/) value for the current
110+
* page and calls the `callback` function once the value is ready, along with
111+
* the `event` performance entries reported for that interaction. The reported
112+
* value is a `DOMHighResTimeStamp`.
113+
*
114+
* A custom `durationThreshold` configuration option can optionally be passed to
115+
* control what `event-timing` entries are considered for INP reporting. The
116+
* default threshold is `40`, which means INP scores of less than 40 are
117+
* reported as 0. Note that this will not affect your 75th percentile INP value
118+
* unless that value is also less than 40 (well below the recommended
119+
* [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold).
120+
*
121+
* If the `reportAllChanges` configuration option is set to `true`, the
122+
* `callback` function will be called as soon as the value is initially
123+
* determined as well as any time the value changes throughout the page
124+
* lifespan.
125+
*
126+
* _**Important:** INP should be continually monitored for changes throughout
127+
* the entire lifespan of a page—including if the user returns to the page after
128+
* it's been hidden/backgrounded. However, since browsers often [will not fire
129+
* additional callbacks once the user has backgrounded a
130+
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
131+
* `callback` is always called when the page's visibility state changes to
132+
* hidden. As a result, the `callback` function might be called multiple times
133+
* during the same page load._
134+
*/
135+
export const onINP = (onReport: ReportCallback, opts?: ReportOpts): void => {
136+
// Set defaults
137+
// eslint-disable-next-line no-param-reassign
138+
opts = opts || {};
139+
140+
// https://web.dev/inp/#what's-a-%22good%22-inp-value
141+
// const thresholds = [200, 500];
142+
143+
// TODO(philipwalton): remove once the polyfill is no longer needed.
144+
initInteractionCountPolyfill();
145+
146+
const metric = initMetric('INP');
147+
// eslint-disable-next-line prefer-const
148+
let report: ReturnType<typeof bindReporter>;
149+
150+
const handleEntries = (entries: INPMetric['entries']): void => {
151+
entries.forEach(entry => {
152+
if (entry.interactionId) {
153+
processEntry(entry);
154+
}
155+
156+
// Entries of type `first-input` don't currently have an `interactionId`,
157+
// so to consider them in INP we have to first check that an existing
158+
// entry doesn't match the `duration` and `startTime`.
159+
// Note that this logic assumes that `event` entries are dispatched
160+
// before `first-input` entries. This is true in Chrome but it is not
161+
// true in Firefox; however, Firefox doesn't support interactionId, so
162+
// it's not an issue at the moment.
163+
// TODO(philipwalton): remove once crbug.com/1325826 is fixed.
164+
if (entry.entryType === 'first-input') {
165+
const noMatchingEntry = !longestInteractionList.some(interaction => {
166+
return interaction.entries.some(prevEntry => {
167+
return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime;
168+
});
169+
});
170+
if (noMatchingEntry) {
171+
processEntry(entry);
172+
}
173+
}
174+
});
175+
176+
const inp = estimateP98LongestInteraction();
177+
178+
if (inp && inp.latency !== metric.value) {
179+
metric.value = inp.latency;
180+
metric.entries = inp.entries;
181+
report();
182+
}
183+
};
184+
185+
const po = observe('event', handleEntries, {
186+
// Event Timing entries have their durations rounded to the nearest 8ms,
187+
// so a duration of 40ms would be any event that spans 2.5 or more frames
188+
// at 60Hz. This threshold is chosen to strike a balance between usefulness
189+
// and performance. Running this callback for any interaction that spans
190+
// just one or two frames is likely not worth the insight that could be
191+
// gained.
192+
durationThreshold: opts.durationThreshold || 40,
193+
} as PerformanceObserverInit);
194+
195+
report = bindReporter(onReport, metric, opts.reportAllChanges);
196+
197+
if (po) {
198+
// Also observe entries of type `first-input`. This is useful in cases
199+
// where the first interaction is less than the `durationThreshold`.
200+
po.observe({ type: 'first-input', buffered: true });
201+
202+
onHidden(() => {
203+
handleEntries(po.takeRecords() as INPMetric['entries']);
204+
205+
// If the interaction count shows that there were interactions but
206+
// none were captured by the PerformanceObserver, report a latency of 0.
207+
if (metric.value < 0 && getInteractionCountForNavigation() > 0) {
208+
metric.value = 0;
209+
metric.entries = [];
210+
}
211+
212+
report(true);
213+
});
214+
}
215+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2022 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { Metric } from '../../types';
18+
import { observe } from '../observe';
19+
20+
declare global {
21+
interface Performance {
22+
interactionCount: number;
23+
}
24+
}
25+
26+
let interactionCountEstimate = 0;
27+
let minKnownInteractionId = Infinity;
28+
let maxKnownInteractionId = 0;
29+
30+
const updateEstimate = (entries: Metric['entries']): void => {
31+
(entries as PerformanceEventTiming[]).forEach(e => {
32+
if (e.interactionId) {
33+
minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId);
34+
maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId);
35+
36+
interactionCountEstimate = maxKnownInteractionId ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0;
37+
}
38+
});
39+
};
40+
41+
let po: PerformanceObserver | undefined;
42+
43+
/**
44+
* Returns the `interactionCount` value using the native API (if available)
45+
* or the polyfill estimate in this module.
46+
*/
47+
export const getInteractionCount = (): number => {
48+
return po ? interactionCountEstimate : performance.interactionCount || 0;
49+
};
50+
51+
/**
52+
* Feature detects native support or initializes the polyfill if needed.
53+
*/
54+
export const initInteractionCountPolyfill = (): void => {
55+
if ('interactionCount' in performance || po) return;
56+
57+
po = observe('event', updateEstimate, {
58+
type: 'event',
59+
buffered: true,
60+
durationThreshold: 0,
61+
} as PerformanceObserverInit);
62+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2022 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { LoadState, Metric, ReportCallback } from './base';
18+
19+
/**
20+
* An INP-specific version of the Metric object.
21+
*/
22+
export interface INPMetric extends Metric {
23+
name: 'INP';
24+
entries: PerformanceEventTiming[];
25+
}
26+
27+
/**
28+
* An object containing potentially-helpful debugging information that
29+
* can be sent along with the INP value for the current page visit in order
30+
* to help identify issues happening to real-users in the field.
31+
*/
32+
export interface INPAttribution {
33+
/**
34+
* A selector identifying the element that the user interacted with for
35+
* the event corresponding to INP. This element will be the `target` of the
36+
* `event` dispatched.
37+
*/
38+
eventTarget?: string;
39+
/**
40+
* The time when the user interacted for the event corresponding to INP.
41+
* This time will match the `timeStamp` value of the `event` dispatched.
42+
*/
43+
eventTime?: number;
44+
/**
45+
* The `type` of the `event` dispatched corresponding to INP.
46+
*/
47+
eventType?: string;
48+
/**
49+
* The `PerformanceEventTiming` entry corresponding to INP.
50+
*/
51+
eventEntry?: PerformanceEventTiming;
52+
/**
53+
* The loading state of the document at the time when the even corresponding
54+
* to INP occurred (see `LoadState` for details). If the interaction occurred
55+
* while the document was loading and executing script (e.g. usually in the
56+
* `dom-interactive` phase) it can result in long delays.
57+
*/
58+
loadState?: LoadState;
59+
}
60+
61+
/**
62+
* An INP-specific version of the Metric object with attribution.
63+
*/
64+
export interface INPMetricWithAttribution extends INPMetric {
65+
attribution: INPAttribution;
66+
}
67+
68+
/**
69+
* An INP-specific version of the ReportCallback function.
70+
*/
71+
export interface INPReportCallback extends ReportCallback {
72+
(metric: INPMetric): void;
73+
}
74+
75+
/**
76+
* An INP-specific version of the ReportCallback function with attribution.
77+
*/
78+
export interface INPReportCallbackWithAttribution extends INPReportCallback {
79+
(metric: INPMetricWithAttribution): void;
80+
}

0 commit comments

Comments
 (0)