UNPKG

9.45 kBPlain TextView Raw
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
17import {onBFCacheRestore} from './lib/bfcache.js';
18import {bindReporter} from './lib/bindReporter.js';
19import {initMetric} from './lib/initMetric.js';
20import {observe} from './lib/observe.js';
21import {onHidden} from './lib/onHidden.js';
22import {
23 getInteractionCount,
24 initInteractionCountPolyfill,
25} from './lib/polyfills/interactionCountPolyfill.js';
26import {whenActivated} from './lib/whenActivated.js';
27import {INPMetric, ReportCallback, ReportOpts} from './types.js';
28
29interface Interaction {
30 id: number;
31 latency: number;
32 entries: PerformanceEventTiming[];
33}
34
35// Used to store the interaction count after a bfcache restore, since p98
36// interaction latencies should only consider the current navigation.
37let prevInteractionCount = 0;
38
39/**
40 * Returns the interaction count since the last bfcache restore (or for the
41 * full page lifecycle if there were no bfcache restores).
42 */
43const getInteractionCountForNavigation = () => {
44 return getInteractionCount() - prevInteractionCount;
45};
46
47// To prevent unnecessary memory usage on pages with lots of interactions,
48// store at most 10 of the longest interactions to consider as INP candidates.
49const MAX_INTERACTIONS_TO_CONSIDER = 10;
50
51// A list of longest interactions on the page (by latency) sorted so the
52// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long.
53let longestInteractionList: Interaction[] = [];
54
55// A mapping of longest interactions by their interaction ID.
56// This is used for faster lookup.
57const longestInteractionMap: {[interactionId: string]: Interaction} = {};
58
59/**
60 * Takes a performance entry and adds it to the list of worst interactions
61 * if its duration is long enough to make it among the worst. If the
62 * entry is part of an existing interaction, it is merged and the latency
63 * and entries list is updated as needed.
64 */
65const processEntry = (entry: PerformanceEventTiming) => {
66 // The least-long of the 10 longest interactions.
67 const minLongestInteraction =
68 longestInteractionList[longestInteractionList.length - 1];
69
70 const existingInteraction = longestInteractionMap[entry.interactionId!];
71
72 // Only process the entry if it's possibly one of the ten longest,
73 // or if it's part of an existing interaction.
74 if (
75 existingInteraction ||
76 longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER ||
77 entry.duration > minLongestInteraction.latency
78 ) {
79 // If the interaction already exists, update it. Otherwise create one.
80 if (existingInteraction) {
81 existingInteraction.entries.push(entry);
82 existingInteraction.latency = Math.max(
83 existingInteraction.latency,
84 entry.duration
85 );
86 } else {
87 const interaction = {
88 id: entry.interactionId!,
89 latency: entry.duration,
90 entries: [entry],
91 };
92 longestInteractionMap[interaction.id] = interaction;
93 longestInteractionList.push(interaction);
94 }
95
96 // Sort the entries by latency (descending) and keep only the top ten.
97 longestInteractionList.sort((a, b) => b.latency - a.latency);
98 longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach((i) => {
99 delete longestInteractionMap[i.id];
100 });
101 }
102};
103
104/**
105 * Returns the estimated p98 longest interaction based on the stored
106 * interaction candidates and the interaction count for the current page.
107 */
108const estimateP98LongestInteraction = () => {
109 const candidateInteractionIndex = Math.min(
110 longestInteractionList.length - 1,
111 Math.floor(getInteractionCountForNavigation() / 50)
112 );
113
114 return longestInteractionList[candidateInteractionIndex];
115};
116
117/**
118 * Calculates the [INP](https://web.dev/responsiveness/) value for the current
119 * page and calls the `callback` function once the value is ready, along with
120 * the `event` performance entries reported for that interaction. The reported
121 * value is a `DOMHighResTimeStamp`.
122 *
123 * A custom `durationThreshold` configuration option can optionally be passed to
124 * control what `event-timing` entries are considered for INP reporting. The
125 * default threshold is `40`, which means INP scores of less than 40 are
126 * reported as 0. Note that this will not affect your 75th percentile INP value
127 * unless that value is also less than 40 (well below the recommended
128 * [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold).
129 *
130 * If the `reportAllChanges` configuration option is set to `true`, the
131 * `callback` function will be called as soon as the value is initially
132 * determined as well as any time the value changes throughout the page
133 * lifespan.
134 *
135 * _**Important:** INP should be continually monitored for changes throughout
136 * the entire lifespan of a page—including if the user returns to the page after
137 * it's been hidden/backgrounded. However, since browsers often [will not fire
138 * additional callbacks once the user has backgrounded a
139 * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
140 * `callback` is always called when the page's visibility state changes to
141 * hidden. As a result, the `callback` function might be called multiple times
142 * during the same page load._
143 */
144export const onINP = (onReport: ReportCallback, opts?: ReportOpts) => {
145 // Set defaults
146 opts = opts || {};
147
148 whenActivated(() => {
149 // https://web.dev/inp/#what's-a-%22good%22-inp-value
150 const thresholds = [200, 500];
151
152 // TODO(philipwalton): remove once the polyfill is no longer needed.
153 initInteractionCountPolyfill();
154
155 let metric = initMetric('INP');
156 let report: ReturnType<typeof bindReporter>;
157
158 const handleEntries = (entries: INPMetric['entries']) => {
159 entries.forEach((entry) => {
160 if (entry.interactionId) {
161 processEntry(entry);
162 }
163
164 // Entries of type `first-input` don't currently have an `interactionId`,
165 // so to consider them in INP we have to first check that an existing
166 // entry doesn't match the `duration` and `startTime`.
167 // Note that this logic assumes that `event` entries are dispatched
168 // before `first-input` entries. This is true in Chrome but it is not
169 // true in Firefox; however, Firefox doesn't support interactionId, so
170 // it's not an issue at the moment.
171 // TODO(philipwalton): remove once crbug.com/1325826 is fixed.
172 if (entry.entryType === 'first-input') {
173 const noMatchingEntry = !longestInteractionList.some(
174 (interaction) => {
175 return interaction.entries.some((prevEntry) => {
176 return (
177 entry.duration === prevEntry.duration &&
178 entry.startTime === prevEntry.startTime
179 );
180 });
181 }
182 );
183 if (noMatchingEntry) {
184 processEntry(entry);
185 }
186 }
187 });
188
189 const inp = estimateP98LongestInteraction();
190
191 if (inp && inp.latency !== metric.value) {
192 metric.value = inp.latency;
193 metric.entries = inp.entries;
194 report();
195 }
196 };
197
198 const po = observe('event', handleEntries, {
199 // Event Timing entries have their durations rounded to the nearest 8ms,
200 // so a duration of 40ms would be any event that spans 2.5 or more frames
201 // at 60Hz. This threshold is chosen to strike a balance between usefulness
202 // and performance. Running this callback for any interaction that spans
203 // just one or two frames is likely not worth the insight that could be
204 // gained.
205 durationThreshold: opts!.durationThreshold || 40,
206 } as PerformanceObserverInit);
207
208 report = bindReporter(onReport, metric, thresholds, opts!.reportAllChanges);
209
210 if (po) {
211 // Also observe entries of type `first-input`. This is useful in cases
212 // where the first interaction is less than the `durationThreshold`.
213 po.observe({type: 'first-input', buffered: true});
214
215 onHidden(() => {
216 handleEntries(po.takeRecords() as INPMetric['entries']);
217
218 // If the interaction count shows that there were interactions but
219 // none were captured by the PerformanceObserver, report a latency of 0.
220 if (metric.value < 0 && getInteractionCountForNavigation() > 0) {
221 metric.value = 0;
222 metric.entries = [];
223 }
224
225 report(true);
226 });
227
228 // Only report after a bfcache restore if the `PerformanceObserver`
229 // successfully registered.
230 onBFCacheRestore(() => {
231 longestInteractionList = [];
232 // Important, we want the count for the full page here,
233 // not just for the current navigation.
234 prevInteractionCount = getInteractionCount();
235
236 metric = initMetric('INP');
237 report = bindReporter(
238 onReport,
239 metric,
240 thresholds,
241 opts!.reportAllChanges
242 );
243 });
244 }
245 });
246};