|
| 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 | +}; |
0 commit comments