-
Notifications
You must be signed in to change notification settings - Fork 420
/
onINP.ts
294 lines (259 loc) · 11.4 KB
/
onINP.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
/*
* 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.
*/
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,
INPMetricWithAttribution,
INPReportCallback,
INPReportCallbackWithAttribution,
ReportOpts,
} from '../types.js';
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<number, pendingEntriesGroup> = 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,
);
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);
}
};
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<PerformanceLongAnimationFrameTiming> = 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),
};
};
/**
* 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
* the `event` performance entries reported for that interaction. The reported
* value is a `DOMHighResTimeStamp`.
*
* A custom `durationThreshold` configuration option can optionally be passed to
* control what `event-timing` entries are considered for INP reporting. The
* default threshold is `40`, which means INP scores of less than 40 are
* reported as 0. Note that this will not affect your 75th percentile INP value
* unless that value is also less than 40 (well below the recommended
* [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).
*
* If the `reportAllChanges` configuration option is set to `true`, the
* `callback` function will be called as soon as the value is initially
* determined as well as any time the value changes throughout the page
* lifespan.
*
* _**Important:** INP should be continually monitored for changes throughout
* the entire lifespan of a page—including if the user returns to the page after
* it's been hidden/backgrounded. However, since browsers often [will not fire
* additional callbacks once the user has backgrounded a
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
* `callback` is always called when the page's visibility state changes to
* hidden. As a result, the `callback` function might be called multiple times
* during the same page load._
*/
export const onINP = (
onReport: INPReportCallbackWithAttribution,
opts?: ReportOpts,
) => {
if (!loafObserver) {
loafObserver = observe('long-animation-frame', handleLoAFEntries);
}
unattributedOnINP(
((metric: INPMetricWithAttribution) => {
// 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,
);
};