17
17
import { WINDOW } from '../../types' ;
18
18
import { bindReporter } from './lib/bindReporter' ;
19
19
import { initMetric } from './lib/initMetric' ;
20
+ import { DEFAULT_DURATION_THRESHOLD , estimateP98LongestInteraction , processInteractionEntry } from './lib/interactions' ;
20
21
import { observe } from './lib/observe' ;
21
22
import { onHidden } from './lib/onHidden' ;
22
- import { getInteractionCount , initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill' ;
23
+ import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill' ;
23
24
import { whenActivated } from './lib/whenActivated' ;
24
- import type { INPMetric , INPReportCallback , MetricRatingThresholds , ReportOpts } from './types ' ;
25
+ import { whenIdle } from './lib/whenIdle ' ;
25
26
26
- interface Interaction {
27
- id : number ;
28
- latency : number ;
29
- entries : PerformanceEventTiming [ ] ;
30
- }
27
+ import type { INPMetric , MetricRatingThresholds , ReportOpts } from './types' ;
31
28
32
29
/** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */
33
30
export const INPThresholds : MetricRatingThresholds = [ 200 , 500 ] ;
34
31
35
- // Used to store the interaction count after a bfcache restore, since p98
36
- // interaction latencies should only consider the current navigation.
37
- const 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
- */
43
- const 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.
49
- const 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.
53
- const longestInteractionList : Interaction [ ] = [ ] ;
54
-
55
- // A mapping of longest interactions by their interaction ID.
56
- // This is used for faster lookup.
57
- const 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
- */
65
- const processEntry = ( entry : PerformanceEventTiming ) => {
66
- // The least-long of the 10 longest interactions.
67
- const minLongestInteraction = longestInteractionList [ longestInteractionList . length - 1 ] ;
68
-
69
- const existingInteraction = longestInteractionMap [ entry . interactionId ! ] ;
70
-
71
- // Only process the entry if it's possibly one of the ten longest,
72
- // or if it's part of an existing interaction.
73
- if (
74
- existingInteraction ||
75
- longestInteractionList . length < MAX_INTERACTIONS_TO_CONSIDER ||
76
- ( minLongestInteraction && entry . duration > minLongestInteraction . latency )
77
- ) {
78
- // If the interaction already exists, update it. Otherwise create one.
79
- if ( existingInteraction ) {
80
- existingInteraction . entries . push ( entry ) ;
81
- existingInteraction . latency = Math . max ( existingInteraction . latency , entry . duration ) ;
82
- } else {
83
- const interaction = {
84
- id : entry . interactionId ! ,
85
- latency : entry . duration ,
86
- entries : [ entry ] ,
87
- } ;
88
- longestInteractionMap [ interaction . id ] = interaction ;
89
- longestInteractionList . push ( interaction ) ;
90
- }
91
-
92
- // Sort the entries by latency (descending) and keep only the top ten.
93
- longestInteractionList . sort ( ( a , b ) => b . latency - a . latency ) ;
94
- longestInteractionList . splice ( MAX_INTERACTIONS_TO_CONSIDER ) . forEach ( i => {
95
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
96
- delete longestInteractionMap [ i . id ] ;
97
- } ) ;
98
- }
99
- } ;
100
-
101
- /**
102
- * Returns the estimated p98 longest interaction based on the stored
103
- * interaction candidates and the interaction count for the current page.
104
- */
105
- const estimateP98LongestInteraction = ( ) => {
106
- const candidateInteractionIndex = Math . min (
107
- longestInteractionList . length - 1 ,
108
- Math . floor ( getInteractionCountForNavigation ( ) / 50 ) ,
109
- ) ;
110
-
111
- return longestInteractionList [ candidateInteractionIndex ] ;
112
- } ;
113
-
114
32
/**
115
33
* Calculates the [INP](https://web.dev/articles/inp) value for the current
116
34
* page and calls the `callback` function once the value is ready, along with
@@ -138,7 +56,12 @@ const estimateP98LongestInteraction = () => {
138
56
* hidden. As a result, the `callback` function might be called multiple times
139
57
* during the same page load._
140
58
*/
141
- export const onINP = ( onReport : INPReportCallback , opts : ReportOpts = { } ) => {
59
+ export const onINP = ( onReport : ( metric : INPMetric ) => void , opts : ReportOpts = { } ) => {
60
+ // Return if the browser doesn't support all APIs needed to measure INP.
61
+ if ( ! ( 'PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming . prototype ) ) {
62
+ return ;
63
+ }
64
+
142
65
whenActivated ( ( ) => {
143
66
// TODO(philipwalton): remove once the polyfill is no longer needed.
144
67
initInteractionCountPolyfill ( ) ;
@@ -148,37 +71,23 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
148
71
let report : ReturnType < typeof bindReporter > ;
149
72
150
73
const handleEntries = ( entries : INPMetric [ 'entries' ] ) => {
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 (the only browser
161
- // that currently supports INP).
162
- // TODO(philipwalton): remove once crbug.com/1325826 is fixed.
163
- if ( entry . entryType === 'first-input' ) {
164
- const noMatchingEntry = ! longestInteractionList . some ( interaction => {
165
- return interaction . entries . some ( prevEntry => {
166
- return entry . duration === prevEntry . duration && entry . startTime === prevEntry . startTime ;
167
- } ) ;
168
- } ) ;
169
- if ( noMatchingEntry ) {
170
- processEntry ( entry ) ;
171
- }
74
+ // Queue the `handleEntries()` callback in the next idle task.
75
+ // This is needed to increase the chances that all event entries that
76
+ // occurred between the user interaction and the next paint
77
+ // have been dispatched. Note: there is currently an experiment
78
+ // running in Chrome (EventTimingKeypressAndCompositionInteractionId)
79
+ // 123+ that if rolled out fully may make this no longer necessary.
80
+ whenIdle ( ( ) => {
81
+ entries . forEach ( processInteractionEntry ) ;
82
+
83
+ const inp = estimateP98LongestInteraction ( ) ;
84
+
85
+ if ( inp && inp . latency !== metric . value ) {
86
+ metric . value = inp . latency ;
87
+ metric . entries = inp . entries ;
88
+ report ( ) ;
172
89
}
173
90
} ) ;
174
-
175
- const inp = estimateP98LongestInteraction ( ) ;
176
-
177
- if ( inp && inp . latency !== metric . value ) {
178
- metric . value = inp . latency ;
179
- metric . entries = inp . entries ;
180
- report ( ) ;
181
- }
182
91
} ;
183
92
184
93
const po = observe ( 'event' , handleEntries , {
@@ -188,29 +97,18 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
188
97
// and performance. Running this callback for any interaction that spans
189
98
// just one or two frames is likely not worth the insight that could be
190
99
// gained.
191
- durationThreshold : opts . durationThreshold != null ? opts . durationThreshold : 40 ,
192
- } as PerformanceObserverInit ) ;
100
+ durationThreshold : opts . durationThreshold != null ? opts . durationThreshold : DEFAULT_DURATION_THRESHOLD ,
101
+ } ) ;
193
102
194
103
report = bindReporter ( onReport , metric , INPThresholds , opts . reportAllChanges ) ;
195
104
196
105
if ( po ) {
197
- // If browser supports interactionId (and so supports INP), also
198
- // observe entries of type `first-input`. This is useful in cases
106
+ // Also observe entries of type `first-input`. This is useful in cases
199
107
// where the first interaction is less than the `durationThreshold`.
200
- if ( 'PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming . prototype ) {
201
- po . observe ( { type : 'first-input' , buffered : true } ) ;
202
- }
108
+ po . observe ( { type : 'first-input' , buffered : true } ) ;
203
109
204
110
onHidden ( ( ) => {
205
111
handleEntries ( po . takeRecords ( ) as INPMetric [ 'entries' ] ) ;
206
-
207
- // If the interaction count shows that there were interactions but
208
- // none were captured by the PerformanceObserver, report a latency of 0.
209
- if ( metric . value < 0 && getInteractionCountForNavigation ( ) > 0 ) {
210
- metric . value = 0 ;
211
- metric . entries = [ ] ;
212
- }
213
-
214
112
report ( true ) ;
215
113
} ) ;
216
114
}
0 commit comments