1616
1717import { bindReporter } from './lib/bindReporter' ;
1818import { getActivationStart } from './lib/getActivationStart' ;
19+ import { getNavigationEntry } from './lib/getNavigationEntry' ;
1920import { getVisibilityWatcher } from './lib/getVisibilityWatcher' ;
20- import { addPageListener , removePageListener } from './lib/globalListeners' ;
21+ import { addPageListener } from './lib/globalListeners' ;
2122import { initMetric } from './lib/initMetric' ;
2223import { initUnique } from './lib/initUnique' ;
2324import { LCPEntryManager } from './lib/LCPEntryManager' ;
2425import { observe } from './lib/observe' ;
25- import { runOnce } from './lib/runOnce ' ;
26+ import { getSoftNavigationEntry , softNavs } from './lib/softNavs ' ;
2627import { whenActivated } from './lib/whenActivated' ;
2728import { whenIdleOrHidden } from './lib/whenIdleOrHidden' ;
28- import type { LCPMetric , MetricRatingThresholds , ReportOpts } from './types' ;
29+ import type { LCPMetric , Metric , MetricRatingThresholds , ReportOpts } from './types' ;
2930
3031/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
3132export const LCPThresholds : MetricRatingThresholds = [ 2500 , 4000 ] ;
@@ -42,22 +43,63 @@ export const LCPThresholds: MetricRatingThresholds = [2500, 4000];
4243 * been determined.
4344 */
4445export const onLCP = ( onReport : ( metric : LCPMetric ) => void , opts : ReportOpts = { } ) => {
46+ let reportedMetric = false ;
47+ const softNavsEnabled = softNavs ( opts ) ;
48+ let metricNavStartTime = 0 ;
49+ const hardNavId = getNavigationEntry ( ) ?. navigationId || '1' ;
50+ let finalizeNavId = '' ;
51+
4552 whenActivated ( ( ) => {
46- const visibilityWatcher = getVisibilityWatcher ( ) ;
47- const metric = initMetric ( 'LCP' ) ;
53+ let visibilityWatcher = getVisibilityWatcher ( ) ;
54+ let metric = initMetric ( 'LCP' ) ;
4855 let report : ReturnType < typeof bindReporter > ;
4956
5057 const lcpEntryManager = initUnique ( opts , LCPEntryManager ) ;
5158
59+ const initNewLCPMetric = ( navigation ?: Metric [ 'navigationType' ] , navigationId ?: string ) => {
60+ metric = initMetric ( 'LCP' , 0 , navigation , navigationId ) ;
61+ report = bindReporter ( onReport , metric , LCPThresholds , opts . reportAllChanges ) ;
62+ reportedMetric = false ;
63+ if ( navigation === 'soft-navigation' ) {
64+ visibilityWatcher = getVisibilityWatcher ( true ) ;
65+ const softNavEntry = getSoftNavigationEntry ( navigationId ) ;
66+ metricNavStartTime = softNavEntry ?. startTime ?? 0 ;
67+ }
68+ } ;
69+
5270 const handleEntries = ( entries : LCPMetric [ 'entries' ] ) => {
5371 // If reportAllChanges is set then call this function for each entry,
54- // otherwise only consider the last one.
55- if ( ! opts . reportAllChanges ) {
72+ // otherwise only consider the last one, unless soft navs are enabled .
73+ if ( ! opts . reportAllChanges && ! softNavsEnabled ) {
5674 // eslint-disable-next-line no-param-reassign
5775 entries = entries . slice ( - 1 ) ;
5876 }
5977
6078 for ( const entry of entries ) {
79+ if ( softNavsEnabled && entry ?. navigationId !== metric . navigationId ) {
80+ // If the entry is for a new navigationId than previous, then we have
81+ // entered a new soft nav, so emit the final LCP and reinitialize the
82+ // metric.
83+ if ( ! reportedMetric ) report ( true ) ;
84+ initNewLCPMetric ( 'soft-navigation' , entry . navigationId ) ;
85+ }
86+ let value = 0 ;
87+ if ( ! entry . navigationId || entry . navigationId === hardNavId ) {
88+ // The startTime attribute returns the value of the renderTime if it is
89+ // not 0, and the value of the loadTime otherwise. The activationStart
90+ // reference is used because LCP should be relative to page activation
91+ // rather than navigation start if the page was prerendered. But in cases
92+ // where `activationStart` occurs after the LCP, this time should be
93+ // clamped at 0.
94+ value = Math . max ( entry . startTime - getActivationStart ( ) , 0 ) ;
95+ } else {
96+ // As a soft nav needs an interaction, it should never be before
97+ // getActivationStart so can just cap to 0
98+ const softNavEntry = getSoftNavigationEntry ( entry . navigationId ) ;
99+ const softNavEntryStartTime = softNavEntry ?. startTime ?? 0 ;
100+ value = Math . max ( entry . startTime - softNavEntryStartTime , 0 ) ;
101+ }
102+
61103 lcpEntryManager . _processEntry ( entry ) ;
62104
63105 // Only report if the page wasn't hidden prior to LCP.
@@ -68,36 +110,37 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts =
68110 // rather than navigation start if the page was prerendered. But in cases
69111 // where `activationStart` occurs after the LCP, this time should be
70112 // clamped at 0.
71- metric . value = Math . max ( entry . startTime - getActivationStart ( ) , 0 ) ;
113+ metric . value = value ;
72114 metric . entries = [ entry ] ;
73115 report ( ) ;
74116 }
75117 }
76118 } ;
77119
78- const po = observe ( 'largest-contentful-paint' , handleEntries ) ;
120+ const po = observe ( 'largest-contentful-paint' , handleEntries , opts ) ;
79121
80122 if ( po ) {
81123 report = bindReporter ( onReport , metric , LCPThresholds , opts . reportAllChanges ) ;
82124
83- // Ensure this logic only runs once, since it can be triggered from
84- // any of three different event listeners below.
85- const stopListening = runOnce ( ( ) => {
86- handleEntries ( po . takeRecords ( ) as LCPMetric [ 'entries' ] ) ;
87- po . disconnect ( ) ;
88- report ( true ) ;
89- } ) ;
90-
91- // Need a separate wrapper to ensure the `runOnce` function above is
92- // common for all three functions
93- const stopListeningWrapper = ( event : Event ) => {
94- if ( event . isTrusted ) {
125+ const finalizeLCP = ( event : Event ) => {
126+ if ( event . isTrusted && ! reportedMetric ) {
127+ // Finalize the current navigationId metric.
128+ finalizeNavId = metric . navigationId ;
95129 // Wrap the listener in an idle callback so it's run in a separate
96130 // task to reduce potential INP impact.
97131 // https://github.com/GoogleChrome/web-vitals/issues/383
98- whenIdleOrHidden ( stopListening ) ;
99- removePageListener ( event . type , stopListeningWrapper , {
100- capture : true ,
132+ whenIdleOrHidden ( ( ) => {
133+ if ( ! reportedMetric ) {
134+ handleEntries ( po . takeRecords ( ) as LCPMetric [ 'entries' ] ) ;
135+ if ( ! softNavsEnabled ) {
136+ po . disconnect ( ) ;
137+ removeEventListener ( event . type , finalizeLCP ) ;
138+ }
139+ if ( metric . navigationId === finalizeNavId ) {
140+ reportedMetric = true ;
141+ report ( true ) ;
142+ }
143+ }
101144 } ) ;
102145 }
103146 } ;
@@ -107,10 +150,40 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts =
107150 // unreliable since it can be programmatically generated.
108151 // See: https://github.com/GoogleChrome/web-vitals/issues/75
109152 for ( const type of [ 'keydown' , 'click' , 'visibilitychange' ] ) {
110- addPageListener ( type , stopListeningWrapper , {
153+ addPageListener ( type , finalizeLCP , {
111154 capture : true ,
112155 } ) ;
113156 }
157+
158+ // Soft navs may be detected by navigationId changes in metrics above
159+ // But where no metric is issued we need to also listen for soft nav
160+ // entries, then emit the final metric for the previous navigation and
161+ // reset the metric for the new navigation.
162+ //
163+ // As PO is ordered by time, these should not happen before metrics.
164+ //
165+ // We add a check on startTime as we may be processing many entries that
166+ // are already dealt with so just checking navigationId differs from
167+ // current metric's navigation id, as we did above, is not sufficient.
168+ const handleSoftNavEntries = ( entries : SoftNavigationEntry [ ] ) => {
169+ entries . forEach ( entry => {
170+ const softNavEntry = entry . navigationId ? getSoftNavigationEntry ( entry . navigationId ) : null ;
171+ if (
172+ entry ?. navigationId !== metric . navigationId &&
173+ softNavEntry ?. startTime &&
174+ softNavEntry . startTime > metricNavStartTime
175+ ) {
176+ handleEntries ( po . takeRecords ( ) as LCPMetric [ 'entries' ] ) ;
177+ if ( ! reportedMetric ) report ( true ) ;
178+ initNewLCPMetric ( 'soft-navigation' , entry . navigationId ) ;
179+ }
180+ } ) ;
181+ } ;
182+
183+ if ( softNavsEnabled ) {
184+ observe ( 'interaction-contentful-paint' , handleEntries , opts ) ;
185+ observe ( 'soft-navigation' , handleSoftNavEntries , opts ) ;
186+ }
114187 }
115188 } ) ;
116189} ;
0 commit comments