1717import { WINDOW } from '../../types' ;
1818import { bindReporter } from './lib/bindReporter' ;
1919import { initMetric } from './lib/initMetric' ;
20+ import { DEFAULT_DURATION_THRESHOLD , estimateP98LongestInteraction , processInteractionEntry } from './lib/interactions' ;
2021import { observe } from './lib/observe' ;
2122import { onHidden } from './lib/onHidden' ;
22- import { getInteractionCount , initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill' ;
23+ import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill' ;
2324import { whenActivated } from './lib/whenActivated' ;
24- import type { INPMetric , INPReportCallback , MetricRatingThresholds , ReportOpts } from './types ' ;
25+ import { whenIdle } from './lib/whenIdle ' ;
2526
26- interface Interaction {
27- id : number ;
28- latency : number ;
29- entries : PerformanceEventTiming [ ] ;
30- }
27+ import type { INPMetric , MetricRatingThresholds , ReportOpts } from './types' ;
3128
3229/** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */
3330export const INPThresholds : MetricRatingThresholds = [ 200 , 500 ] ;
3431
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-
11432/**
11533 * Calculates the [INP](https://web.dev/articles/inp) value for the current
11634 * page and calls the `callback` function once the value is ready, along with
@@ -138,7 +56,12 @@ const estimateP98LongestInteraction = () => {
13856 * hidden. As a result, the `callback` function might be called multiple times
13957 * during the same page load._
14058 */
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+
14265 whenActivated ( ( ) => {
14366 // TODO(philipwalton): remove once the polyfill is no longer needed.
14467 initInteractionCountPolyfill ( ) ;
@@ -148,37 +71,23 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
14871 let report : ReturnType < typeof bindReporter > ;
14972
15073 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 ( ) ;
17289 }
17390 } ) ;
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- }
18291 } ;
18392
18493 const po = observe ( 'event' , handleEntries , {
@@ -188,29 +97,18 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
18897 // and performance. Running this callback for any interaction that spans
18998 // just one or two frames is likely not worth the insight that could be
19099 // gained.
191- durationThreshold : opts . durationThreshold != null ? opts . durationThreshold : 40 ,
192- } as PerformanceObserverInit ) ;
100+ durationThreshold : opts . durationThreshold != null ? opts . durationThreshold : DEFAULT_DURATION_THRESHOLD ,
101+ } ) ;
193102
194103 report = bindReporter ( onReport , metric , INPThresholds , opts . reportAllChanges ) ;
195104
196105 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
199107 // 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 } ) ;
203109
204110 onHidden ( ( ) => {
205111 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-
214112 report ( true ) ;
215113 } ) ;
216114 }
0 commit comments