diff --git a/README.md b/README.md index 639684f5..78f462fb 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,67 @@ onLCP(logDelta); In addition to using the `id` field to group multiple deltas for the same metric, it can also be used to differentiate different metrics reported on the same page. For example, after a back/forward cache restore, a new metric object is created with a new `id` (since back/forward cache restores are considered separate page visits). +### Report metrics for soft navigations (experimental) + +_**Note:** this is experimental and subject to change._ + +Currently Core Web Vitals are only tracked for full page navigations, which can affect how [Single Page Applications](https://web.dev/vitals-spa-faq/) that use so called "soft navigations" to update the browser URL and history outside of the normal browser's handling of this. The Chrome team are experimenting with being able to [measure these soft navigations](https://github.com/WICG/soft-navigations) separately and report on Core Web Vitals separately for them. + +This experimental support allows sites to measure how their Core Web Vitals might be measured differently should this happen. + +At present a "soft navigation" is defined as happening after the following three things happen: + +- A user interaction occurs +- The URL changes +- Content is added to the DOM +- Something is painted to screen. + +For some sites, these heuristics may lead to false positives (that users would not really consider a "navigation"), or false negatives (where the user does consider a navigation to have happened despite not missing the above criteria). We welcome feedback at https://github.com/WICG/soft-navigations/issues on the heuristics, at https://crbug.com for bugs in the Chrome implementation, and on [https://github.com/GoogleChrome/web-vitals/pull/308](this pull request) for implementation issues with web-vitals.js. + +_**Note:** At this time it is not known if this experiment will be something we want to move forward with. Until such time, this support will likely remain in a separate branch of this project, rather than be included in any production builds. If we decide not to move forward with this, the support of this will likely be removed from this project since this library is intended to mirror the Core Web Vitals as much as possible._ + +Some important points to note: + +- TTFB is reported as 0, and not the time of the first network call (if any) after the soft navigation. +- FCP and LCP are the first and largest contentful paints after the soft navigation. Prior reported paint times will not be counted for these metrics, even though these elements may remain between soft navigations, and may be the first or largest contentful item. +- FID is reset to measure the first interaction after the soft navigation. +- INP is reset to measure only interactions after the the soft navigation. +- CLS is reset to measure again separate to the first page. + +_**Note:** It is not known at this time whether soft navigations will be weighted the same as full navigations. No weighting is included in this library at present and metrics are reported in the same way as full page load metrics._ + +The metrics can be reported for Soft Navigations using the `reportSoftNavs: true` reporting option: + +```js +import { + onCLS, + onINP, + onLCP, +} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module'; + +onCLS(console.log, {reportSoftNavs: true}); +onINP(console.log, {reportSoftNavs: true}); +onLCP(console.log, {reportSoftNavs: true}); +``` + +Note that this will change the way the first page loads are measured as the metrics for the inital URL will be finalized once the first soft nav occurs. To measure both you need to register two callbacks: + +```js +import { + onCLS, + onINP, + onLCP, +} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module'; + +onCLS(doTraditionalProcessing); +onINP(doTraditionalProcessing); +onLCP(doTraditionalProcessing); + +onCLS(doSoftNavProcessing, {reportSoftNavs: true}); +onINP(doSoftNavProcessing, {reportSoftNavs: true}); +onLCP(doSoftNavProcessing, {reportSoftNavs: true}); +``` + ### Send the results to an analytics endpoint The following example measures each of the Core Web Vitals metrics and reports them to a hypothetical `/analytics` endpoint, as soon as each is ready to be sent. @@ -552,6 +613,7 @@ interface Metric { * - 'prerender': for pages that were prerendered. * - 'restore': for pages that were discarded by the browser and then * restored by the user. + * - 'soft-navigation': for soft navigations. */ navigationType: | 'navigate' @@ -559,7 +621,14 @@ interface Metric { | 'back-forward' | 'back-forward-cache' | 'prerender' - | 'restore'; + | 'restore' + | 'soft-navigation'; + + /** + * The navigatonId the metric happened for. This is particularly relevent for soft navigations where + * the metric may be reported for a previous URL. + */ + navigatonId: number; } ``` @@ -648,6 +717,7 @@ _See also [Rating Thresholds](#rating-thresholds)._ interface ReportOpts { reportAllChanges?: boolean; durationThreshold?: number; + reportSoftNavs?: boolean; } ``` @@ -861,7 +931,7 @@ interface FCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ navigationEntry?: PerformanceNavigationTiming; } @@ -1031,7 +1101,7 @@ interface LCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ navigationEntry?: PerformanceNavigationTiming; /** @@ -1081,7 +1151,7 @@ export interface TTFBAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for - * example: navigationEntry?.serverTiming + * example: navigationEntry.serverTiming */ navigationEntry?: PerformanceNavigationTiming; } diff --git a/package-lock.json b/package-lock.json index 4c04cccf..669030cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "web-vitals", - "version": "4.2.3", + "version": "4.2.3-soft-navs", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web-vitals", - "version": "4.2.3", + "version": "4.2.3-soft-navs", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.23.6", diff --git a/package.json b/package.json index ca57344a..c1ec054a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-vitals", - "version": "4.2.3", + "version": "4.2.3-soft-navs", "description": "Easily measure performance metrics in JavaScript", "type": "module", "typings": "dist/modules/index.d.ts", diff --git a/src/attribution/onFCP.ts b/src/attribution/onFCP.ts index 079bf91a..3688e236 100644 --- a/src/attribution/onFCP.ts +++ b/src/attribution/onFCP.ts @@ -17,6 +17,7 @@ import {getBFCacheRestoreTime} from '../lib/bfcache.js'; import {getLoadState} from '../lib/getLoadState.js'; import {getNavigationEntry} from '../lib/getNavigationEntry.js'; +import {getSoftNavigationEntry} from '../lib/softNavs.js'; import {onFCP as unattributedOnFCP} from '../onFCP.js'; import { FCPAttribution, @@ -26,6 +27,7 @@ import { } from '../types.js'; const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => { + const hardNavId = getNavigationEntry()?.navigationId || '1'; // Use a default object if no other attribution has been set. let attribution: FCPAttribution = { timeToFirstByte: 0, @@ -34,13 +36,26 @@ const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => { }; if (metric.entries.length) { - const navigationEntry = getNavigationEntry(); + let navigationEntry; const fcpEntry = metric.entries[metric.entries.length - 1]; - if (navigationEntry) { - const activationStart = navigationEntry.activationStart || 0; - const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); + let ttfb = 0; + let softNavStart = 0; + if (!metric.navigationId || metric.navigationId === hardNavId) { + navigationEntry = getNavigationEntry(); + if (navigationEntry) { + const responseStart = navigationEntry.responseStart; + const activationStart = navigationEntry.activationStart || 0; + ttfb = Math.max(0, responseStart - activationStart); + } + } else { + navigationEntry = getSoftNavigationEntry(metric.navigationId); + // Set ttfb to the SoftNav start time + softNavStart = navigationEntry ? navigationEntry.startTime : 0; + ttfb = softNavStart; + } + if (navigationEntry) { attribution = { timeToFirstByte: ttfb, firstByteToFCP: metric.value - ttfb, diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts index 285f319a..a5f13289 100644 --- a/src/attribution/onLCP.ts +++ b/src/attribution/onLCP.ts @@ -15,6 +15,7 @@ */ import {getNavigationEntry} from '../lib/getNavigationEntry.js'; +import {getSoftNavigationEntry} from '../lib/softNavs.js'; import {getSelector} from '../lib/getSelector.js'; import {onLCP as unattributedOnLCP} from '../onLCP.js'; import { @@ -25,6 +26,7 @@ import { } from '../types.js'; const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { + const hardNavId = getNavigationEntry()?.navigationId || '1'; // Use a default object if no other attribution has been set. let attribution: LCPAttribution = { timeToFirstByte: 0, @@ -34,9 +36,29 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { }; if (metric.entries.length) { - const navigationEntry = getNavigationEntry(); + let navigationEntry; + let activationStart = 0; + let responseStart = 0; + let softNavStart = 0; + + if (!metric.navigationId || metric.navigationId === hardNavId) { + navigationEntry = getNavigationEntry(); + activationStart = + navigationEntry && navigationEntry.activationStart + ? navigationEntry.activationStart + : 0; + responseStart = + navigationEntry && navigationEntry.responseStart + ? navigationEntry.responseStart + : 0; + } else { + navigationEntry = getSoftNavigationEntry(metric.navigationId); + // Set activationStart to the SoftNav start time + softNavStart = navigationEntry ? navigationEntry.startTime : 0; + activationStart = softNavStart; + } + if (navigationEntry) { - const activationStart = navigationEntry.activationStart || 0; const lcpEntry = metric.entries[metric.entries.length - 1]; const lcpResourceEntry = lcpEntry.url && @@ -44,7 +66,7 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { .getEntriesByType('resource') .filter((e) => e.name === lcpEntry.url)[0]; - const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); + const ttfb = Math.max(0, responseStart - activationStart); const lcpRequestStart = Math.max( ttfb, @@ -55,12 +77,14 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { : 0, ); const lcpResponseEnd = Math.max( - lcpRequestStart, + lcpRequestStart - softNavStart, lcpResourceEntry ? lcpResourceEntry.responseEnd - activationStart : 0, + 0, ); const lcpRenderTime = Math.max( - lcpResponseEnd, - lcpEntry.startTime - activationStart, + lcpResponseEnd - softNavStart, + lcpEntry ? lcpEntry.startTime - activationStart : 0, + 0, ); attribution = { diff --git a/src/attribution/onTTFB.ts b/src/attribution/onTTFB.ts index 1cfd74bc..155d2b4e 100644 --- a/src/attribution/onTTFB.ts +++ b/src/attribution/onTTFB.ts @@ -33,7 +33,10 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => { }; if (metric.entries.length) { - const navigationEntry = metric.entries[0]; + // Is there a better way to check if this is a soft nav entry or not? + // Refuses to build without this as soft navs don't have activationStart + const navigationEntry = metric.entries[0]; + const activationStart = navigationEntry.activationStart || 0; // Measure from workerStart or fetchStart so any service worker startup @@ -45,15 +48,15 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => { 0, ); const dnsStart = Math.max( - navigationEntry.domainLookupStart - activationStart, + navigationEntry.domainLookupStart - activationStart || 0, 0, ); const connectStart = Math.max( - navigationEntry.connectStart - activationStart, + navigationEntry.connectStart - activationStart || 0, 0, ); const connectEnd = Math.max( - navigationEntry.connectEnd - activationStart, + navigationEntry.connectEnd - activationStart || 0, 0, ); diff --git a/src/lib/getActivationStart.ts b/src/lib/getActivationStart.ts index 3991c0e9..16d3454c 100644 --- a/src/lib/getActivationStart.ts +++ b/src/lib/getActivationStart.ts @@ -17,6 +17,6 @@ import {getNavigationEntry} from './getNavigationEntry.js'; export const getActivationStart = (): number => { - const navEntry = getNavigationEntry(); - return (navEntry && navEntry.activationStart) || 0; + const hardNavEntry = getNavigationEntry(); + return (hardNavEntry && hardNavEntry.activationStart) || 0; }; diff --git a/src/lib/getLoadState.ts b/src/lib/getLoadState.ts index 788db0f4..1231cf3e 100644 --- a/src/lib/getLoadState.ts +++ b/src/lib/getLoadState.ts @@ -23,20 +23,20 @@ export const getLoadState = (timestamp: number): LoadState => { // since the timestamp has to be the current time or earlier. return 'loading'; } else { - const navigationEntry = getNavigationEntry(); - if (navigationEntry) { - if (timestamp < navigationEntry.domInteractive) { + const hardNavEntry = getNavigationEntry(); + if (hardNavEntry) { + if (timestamp < hardNavEntry.domInteractive) { return 'loading'; } else if ( - navigationEntry.domContentLoadedEventStart === 0 || - timestamp < navigationEntry.domContentLoadedEventStart + hardNavEntry.domContentLoadedEventStart === 0 || + timestamp < hardNavEntry.domContentLoadedEventStart ) { // If the `domContentLoadedEventStart` timestamp has not yet been // set, or if the given timestamp is less than that value. return 'dom-interactive'; } else if ( - navigationEntry.domComplete === 0 || - timestamp < navigationEntry.domComplete + hardNavEntry.domComplete === 0 || + timestamp < hardNavEntry.domComplete ) { // If the `domComplete` timestamp has not yet been // set, or if the given timestamp is less than that value. diff --git a/src/lib/getVisibilityWatcher.ts b/src/lib/getVisibilityWatcher.ts index ad7b1c3f..b4502f44 100644 --- a/src/lib/getVisibilityWatcher.ts +++ b/src/lib/getVisibilityWatcher.ts @@ -61,7 +61,10 @@ const removeChangeListeners = () => { removeEventListener('prerenderingchange', onVisibilityUpdate, true); }; -export const getVisibilityWatcher = () => { +export const getVisibilityWatcher = (reset = false) => { + if (reset) { + firstHiddenTime = -1; + } if (firstHiddenTime < 0) { // If the document is hidden when this code runs, assume it was hidden // since navigation start. This isn't a perfect heuristic, but it's the diff --git a/src/lib/initMetric.ts b/src/lib/initMetric.ts index 90618d81..81eb3e40 100644 --- a/src/lib/initMetric.ts +++ b/src/lib/initMetric.ts @@ -23,19 +23,25 @@ import {MetricType} from '../types.js'; export const initMetric = ( name: MetricName, value?: number, + navigation?: MetricType['navigationType'], + navigationId?: string, ) => { - const navEntry = getNavigationEntry(); + const hardNavId = getNavigationEntry()?.navigationId || '1'; + const hardNavEntry = getNavigationEntry(); let navigationType: MetricType['navigationType'] = 'navigate'; - if (getBFCacheRestoreTime() >= 0) { + if (navigation) { + // If it was passed in, then use that + navigationType = navigation; + } else if (getBFCacheRestoreTime() >= 0) { navigationType = 'back-forward-cache'; - } else if (navEntry) { + } else if (hardNavEntry) { if (document.prerendering || getActivationStart() > 0) { navigationType = 'prerender'; } else if (document.wasDiscarded) { navigationType = 'restore'; - } else if (navEntry.type) { - navigationType = navEntry.type.replace( + } else if (hardNavEntry.type) { + navigationType = hardNavEntry.type.replace( /_/g, '-', ) as MetricType['navigationType']; @@ -53,5 +59,6 @@ export const initMetric = ( entries, id: generateUniqueID(), navigationType, + navigationId: navigationId || hardNavId, }; }; diff --git a/src/lib/observe.ts b/src/lib/observe.ts index f3127a3d..428f2c98 100644 --- a/src/lib/observe.ts +++ b/src/lib/observe.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import {softNavs} from './softNavs.js'; + interface PerformanceEntryMap { 'event': PerformanceEventTiming[]; 'first-input': PerformanceEventTiming[]; @@ -23,6 +25,7 @@ interface PerformanceEntryMap { 'paint': PerformancePaintTiming[]; 'navigation': PerformanceNavigationTiming[]; 'resource': PerformanceResourceTiming[]; + 'soft-navigation': SoftNavigationEntry[]; } /** @@ -38,6 +41,7 @@ export const observe = ( callback: (entries: PerformanceEntryMap[K]) => void, opts?: PerformanceObserverInit, ): PerformanceObserver | undefined => { + const includeSoftNavigationObservations = softNavs(opts); try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { const po = new PerformanceObserver((list) => { @@ -53,6 +57,8 @@ export const observe = ( { type, buffered: true, + includeSoftNavigationObservations: + includeSoftNavigationObservations, }, opts || {}, ) as PerformanceObserverInit, diff --git a/src/lib/polyfills/firstInputPolyfill.ts b/src/lib/polyfills/firstInputPolyfill.ts index 804c4238..8f91ce9d 100644 --- a/src/lib/polyfills/firstInputPolyfill.ts +++ b/src/lib/polyfills/firstInputPolyfill.ts @@ -88,7 +88,7 @@ const reportFirstInputDelayIfRecordedAndValid = () => { processingStart: firstInputEvent!.timeStamp + firstInputDelay, } as FirstInputPolyfillEntry; callbacks.forEach(function (callback) { - callback(entry); + callback([entry]); }); callbacks = []; } diff --git a/src/lib/polyfills/interactionCountPolyfill.ts b/src/lib/polyfills/interactionCountPolyfill.ts index e363376e..84689ee1 100644 --- a/src/lib/polyfills/interactionCountPolyfill.ts +++ b/src/lib/polyfills/interactionCountPolyfill.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import {getNavigationEntry} from '../getNavigationEntry.js'; import {observe} from '../observe.js'; declare global { @@ -25,10 +26,23 @@ declare global { let interactionCountEstimate = 0; let minKnownInteractionId = Infinity; let maxKnownInteractionId = 0; +let currentNavId = ''; +let softNavsEnabled = false; const updateEstimate = (entries: PerformanceEventTiming[]) => { + if (!currentNavId) currentNavId = getNavigationEntry()?.navigationId || '1'; entries.forEach((e) => { if (e.interactionId) { + if ( + softNavsEnabled && + e.navigationId && + e.navigationId !== currentNavId + ) { + currentNavId = e.navigationId; + interactionCountEstimate = 0; + minKnownInteractionId = Infinity; + maxKnownInteractionId = 0; + } minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); @@ -52,12 +66,15 @@ export const getInteractionCount = () => { /** * Feature detects native support or initializes the polyfill if needed. */ -export const initInteractionCountPolyfill = () => { +export const initInteractionCountPolyfill = (softNavs?: boolean) => { if ('interactionCount' in performance || po) return; + softNavsEnabled = softNavs || false; + po = observe('event', updateEstimate, { type: 'event', buffered: true, durationThreshold: 0, + includeSoftNavigationObservations: softNavsEnabled, } as PerformanceObserverInit); }; diff --git a/src/lib/softNavs.ts b/src/lib/softNavs.ts new file mode 100644 index 00000000..145a3a5f --- /dev/null +++ b/src/lib/softNavs.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2023 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 {ReportOpts} from '../types.js'; + +export const softNavs = (opts?: ReportOpts) => { + return ( + PerformanceObserver.supportedEntryTypes.includes('soft-navigation') && + opts && + opts.reportSoftNavs + ); +}; + +export const getSoftNavigationEntry = ( + navigationId?: string, +): SoftNavigationEntry | undefined => { + if (!navigationId) return; + + const softNavEntry = window.performance + .getEntriesByType('soft-navigation') + .filter((entry) => entry.navigationId === navigationId); + if (softNavEntry) return softNavEntry[0]; + + return; +}; diff --git a/src/onCLS.ts b/src/onCLS.ts index 6f78458b..bfa9fa7a 100644 --- a/src/onCLS.ts +++ b/src/onCLS.ts @@ -14,15 +14,21 @@ * limitations under the License. */ -import {onBFCacheRestore} from './lib/bfcache.js'; -import {initMetric} from './lib/initMetric.js'; -import {observe} from './lib/observe.js'; import {bindReporter} from './lib/bindReporter.js'; import {doubleRAF} from './lib/doubleRAF.js'; +import {initMetric} from './lib/initMetric.js'; +import {observe} from './lib/observe.js'; +import {onBFCacheRestore} from './lib/bfcache.js'; import {onHidden} from './lib/onHidden.js'; import {runOnce} from './lib/runOnce.js'; +import {getSoftNavigationEntry, softNavs} from './lib/softNavs.js'; import {onFCP} from './onFCP.js'; -import {CLSMetric, MetricRatingThresholds, ReportOpts} from './types.js'; +import { + CLSMetric, + Metric, + MetricRatingThresholds, + ReportOpts, +} from './types.js'; /** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; @@ -54,6 +60,9 @@ export const onCLS = ( ) => { // Set defaults opts = opts || {}; + const softNavsEnabled = softNavs(opts); + let reportedMetric = false; + let metricNavStartTime = 0; // Start monitoring FCP so we can only report CLS if FCP is also reported. // Note: this is done to match the current behavior of CrUX. @@ -65,8 +74,45 @@ export const onCLS = ( let sessionValue = 0; let sessionEntries: LayoutShift[] = []; + const initNewCLSMetric = ( + navigation?: Metric['navigationType'], + navigationId?: string, + ) => { + metric = initMetric('CLS', 0, navigation, navigationId); + report = bindReporter( + onReport, + metric, + CLSThresholds, + opts!.reportAllChanges, + ); + sessionValue = 0; + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry ? softNavEntry.startTime || 0 : 0; + } + }; + const handleEntries = (entries: LayoutShift[]) => { entries.forEach((entry) => { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final LCP and reinitialize the + // metric. + if ( + softNavsEnabled && + entry.navigationId && + entry.navigationId !== metric.navigationId + ) { + // If the current session value is larger than the current CLS value, + // update CLS and the entries contributing to it. + if (sessionValue > metric.value) { + metric.value = sessionValue; + metric.entries = sessionEntries; + } + report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + } + // Only count layout shifts without recent user input. if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0]; @@ -99,7 +145,7 @@ export const onCLS = ( } }; - const po = observe('layout-shift', handleEntries); + const po = observe('layout-shift', handleEntries, opts); if (po) { report = bindReporter( onReport, @@ -111,23 +157,52 @@ export const onCLS = ( onHidden(() => { handleEntries(po.takeRecords() as CLSMetric['entries']); report(true); + reportedMetric = true; }); // Only report after a bfcache restore if the `PerformanceObserver` // successfully registered. onBFCacheRestore(() => { - sessionValue = 0; - metric = initMetric('CLS', 0); - report = bindReporter( - onReport, - metric, - CLSThresholds, - opts!.reportAllChanges, - ); - + initNewCLSMetric('back-forward-cache', metric.navigationId); doubleRAF(() => report()); }); + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach((entry) => { + const navId = entry.navigationId; + const softNavEntry = navId ? getSoftNavigationEntry(navId) : null; + if ( + navId && + navId !== metric.navigationId && + softNavEntry && + (softNavEntry.startTime || 0) > metricNavStartTime + ) { + if (!reportedMetric) report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + report = bindReporter( + onReport, + metric, + CLSThresholds, + opts!.reportAllChanges, + ); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } + // Queue a task to report (if nothing else triggers a report first). // This allows CLS to be reported as soon as FCP fires when // `reportAllChanges` is true. diff --git a/src/onFCP.ts b/src/onFCP.ts index f2a3625a..33424460 100644 --- a/src/onFCP.ts +++ b/src/onFCP.ts @@ -14,15 +14,22 @@ * limitations under the License. */ -import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; import {doubleRAF} from './lib/doubleRAF.js'; import {getActivationStart} from './lib/getActivationStart.js'; +import {getSoftNavigationEntry, softNavs} from './lib/softNavs.js'; import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js'; +import {getNavigationEntry} from './lib/getNavigationEntry.js'; import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; +import {onBFCacheRestore} from './lib/bfcache.js'; import {whenActivated} from './lib/whenActivated.js'; -import {FCPMetric, MetricRatingThresholds, ReportOpts} from './types.js'; +import { + FCPMetric, + Metric, + MetricRatingThresholds, + ReportOpts, +} from './types.js'; /** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; @@ -39,32 +46,98 @@ export const onFCP = ( ) => { // Set defaults opts = opts || {}; + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); + let visibilityWatcher = getVisibilityWatcher(); let metric = initMetric('FCP'); let report: ReturnType; + const initNewFCPMetric = ( + navigation?: Metric['navigationType'], + navigationId?: string, + ) => { + metric = initMetric('FCP', 0, navigation, navigationId); + report = bindReporter( + onReport, + metric, + FCPThresholds, + opts!.reportAllChanges, + ); + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = navigationId + ? getSoftNavigationEntry(navigationId) + : null; + metricNavStartTime = softNavEntry ? softNavEntry.startTime || 0 : 0; + } + }; + const handleEntries = (entries: FCPMetric['entries']) => { entries.forEach((entry) => { if (entry.name === 'first-contentful-paint') { - po!.disconnect(); + if (!softNavsEnabled) { + // If we're not using soft navs monitoring, we should not see + // any more FCPs so can discconnect the performance observer + po!.disconnect(); + } else if ( + entry.navigationId && + entry.navigationId !== metric.navigationId + ) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so reinitialize the metric. + initNewFCPMetric('soft-navigation', entry.navigationId); + } + + let value = 0; - // Only report if the page wasn't hidden prior to the first paint. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { + if (!entry.navigationId || entry.navigationId === hardNavId) { + // Only report if the page wasn't hidden prior to the first paint. // The activationStart reference is used because FCP should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the FCP, this time should be clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavStartTime = + softNavEntry && softNavEntry.startTime + ? softNavEntry.startTime + : 0; + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + value = Math.max(entry.startTime - softNavStartTime, 0); + } + + // Only report if the page wasn't hidden prior to FCP. + // Or it's a soft nav FCP + const softNavEntry = + softNavsEnabled && entry.navigationId + ? getSoftNavigationEntry(entry.navigationId) + : null; + const softNavEntryStartTime = + softNavEntry && softNavEntry.startTime ? softNavEntry.startTime : 0; + if ( + entry.startTime < visibilityWatcher.firstHiddenTime || + (softNavsEnabled && + entry.navigationId && + entry.navigationId !== metric.navigationId && + entry.navigationId !== hardNavId && + softNavEntryStartTime > metricNavStartTime) + ) { + metric.value = value; metric.entries.push(entry); + metric.navigationId = entry.navigationId || '1'; + // FCP should only be reported once so can report right report(true); } } }); }; - const po = observe('paint', handleEntries); + const po = observe('paint', handleEntries, opts); if (po) { report = bindReporter( @@ -77,7 +150,12 @@ export const onFCP = ( // Only report after a bfcache restore if the `PerformanceObserver` // successfully registered or the `paint` entry exists. onBFCacheRestore((event) => { - metric = initMetric('FCP'); + metric = initMetric( + 'FCP', + 0, + 'back-forward-cache', + metric.navigationId, + ); report = bindReporter( onReport, metric, diff --git a/src/onFID.ts b/src/onFID.ts index cde84b9d..f4b9509b 100644 --- a/src/onFID.ts +++ b/src/onFID.ts @@ -17,6 +17,7 @@ import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js'; +import {getNavigationEntry} from './lib/getNavigationEntry.js'; import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; @@ -24,11 +25,12 @@ import { firstInputPolyfill, resetFirstInputPolyfill, } from './lib/polyfills/firstInputPolyfill.js'; -import {runOnce} from './lib/runOnce.js'; +import {softNavs} from './lib/softNavs.js'; import {whenActivated} from './lib/whenActivated.js'; import { FIDMetric, FirstInputPolyfillCallback, + Metric, MetricRatingThresholds, ReportOpts, } from './types.js'; @@ -51,26 +53,52 @@ export const onFID = ( ) => { // Set defaults opts = opts || {}; + const softNavsEnabled = softNavs(opts); + const hardNavId = getNavigationEntry()?.navigationId || '1'; whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); + let visibilityWatcher = getVisibilityWatcher(); let metric = initMetric('FID'); let report: ReturnType; - const handleEntry = (entry: PerformanceEventTiming) => { - // Only report if the page wasn't hidden prior to the first input. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { - metric.value = entry.processingStart - entry.startTime; - metric.entries.push(entry); - report(true); + const initNewFIDMetric = ( + navigation?: Metric['navigationType'], + navigationId?: string, + ) => { + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); } + metric = initMetric('FID', 0, navigation, navigationId); + report = bindReporter( + onReport, + metric, + FIDThresholds, + opts!.reportAllChanges, + ); }; const handleEntries = (entries: FIDMetric['entries']) => { - entries.forEach(handleEntry); + entries.forEach((entry) => { + if (!softNavsEnabled) { + po!.disconnect(); + } else if ( + entry.navigationId && + entry.navigationId !== metric.navigationId + ) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so reinitialize the metric. + initNewFIDMetric('soft-navigation', entry.navigationId); + } + // Only report if the page wasn't hidden prior to the first input. + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + metric.value = entry.processingStart - entry.startTime; + metric.entries.push(entry); + metric.navigationId = entry.navigationId || hardNavId; + report(true); + } + }); }; - - const po = observe('first-input', handleEntries); + const po = observe('first-input', handleEntries, opts); report = bindReporter( onReport, @@ -80,15 +108,18 @@ export const onFID = ( ); if (po) { - onHidden( - runOnce(() => { - handleEntries(po.takeRecords() as FIDMetric['entries']); - po.disconnect(); - }), - ); + onHidden(() => { + handleEntries(po!.takeRecords() as FIDMetric['entries']); + if (!softNavsEnabled) po.disconnect(); + }); onBFCacheRestore(() => { - metric = initMetric('FID'); + metric = initMetric( + 'FID', + 0, + 'back-forward-cache', + metric.navigationId, + ); report = bindReporter( onReport, metric, @@ -98,7 +129,7 @@ export const onFID = ( // Browsers don't re-emit FID on bfcache restore so fake it until you make it resetFirstInputPolyfill(); - firstInputPolyfill(handleEntry as FirstInputPolyfillCallback); + firstInputPolyfill(handleEntries as FirstInputPolyfillCallback); }); } }); diff --git a/src/onINP.ts b/src/onINP.ts index cafa6d2d..ae512532 100644 --- a/src/onINP.ts +++ b/src/onINP.ts @@ -16,6 +16,7 @@ import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; +import {doubleRAF} from './lib/doubleRAF.js'; import {initMetric} from './lib/initMetric.js'; import { DEFAULT_DURATION_THRESHOLD, @@ -26,10 +27,16 @@ import { import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; import {initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js'; +import {getSoftNavigationEntry, softNavs} from './lib/softNavs.js'; import {whenActivated} from './lib/whenActivated.js'; import {whenIdle} from './lib/whenIdle.js'; -import {INPMetric, MetricRatingThresholds, ReportOpts} from './types.js'; +import { + INPMetric, + Metric, + MetricRatingThresholds, + ReportOpts, +} from './types.js'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; @@ -77,14 +84,49 @@ export const onINP = ( // Set defaults opts = opts || {}; + const softNavsEnabled = softNavs(opts); + let reportedMetric = false; + let metricNavStartTime = 0; whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. - initInteractionCountPolyfill(); + initInteractionCountPolyfill(softNavsEnabled); let metric = initMetric('INP'); let report: ReturnType; + const initNewINPMetric = ( + navigation?: Metric['navigationType'], + navigationId?: string, + ) => { + resetInteractions(); + metric = initMetric('INP', 0, navigation, navigationId); + report = bindReporter( + onReport, + metric, + INPThresholds, + opts!.reportAllChanges, + ); + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = + softNavEntry && softNavEntry.startTime ? softNavEntry.startTime : 0; + } + }; + + const updateINPMetric = () => { + const inp = estimateP98LongestInteraction(); + + if ( + inp && + (inp.latency !== metric.value || (opts && opts.reportAllChanges)) + ) { + metric.value = inp.latency; + metric.entries = inp.entries; + } + }; + const handleEntries = (entries: INPMetric['entries']) => { // Queue the `handleEntries()` callback in the next idle task. // This is needed to increase the chances that all event entries that @@ -95,13 +137,8 @@ export const onINP = ( whenIdle(() => { entries.forEach(processInteractionEntry); - const inp = estimateP98LongestInteraction(); - - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; - metric.entries = inp.entries; - report(); - } + updateINPMetric(); + report(); }); }; @@ -113,7 +150,8 @@ export const onINP = ( // just one or two frames is likely not worth the insight that could be // gained. durationThreshold: opts!.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, - }); + opts, + } as PerformanceObserverInit); report = bindReporter( onReport, @@ -125,7 +163,11 @@ export const onINP = ( if (po) { // Also observe entries of type `first-input`. This is useful in cases // where the first interaction is less than the `durationThreshold`. - po.observe({type: 'first-input', buffered: true}); + po.observe({ + type: 'first-input', + buffered: true, + includeSoftNavigationObservations: softNavsEnabled, + }); onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); @@ -136,15 +178,45 @@ export const onINP = ( // successfully registered. onBFCacheRestore(() => { resetInteractions(); - - metric = initMetric('INP'); - report = bindReporter( - onReport, - metric, - INPThresholds, - opts!.reportAllChanges, - ); + initNewINPMetric('back-forward-cache', metric.navigationId); + doubleRAF(() => report()); }); + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach((entry) => { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = + softNavEntry && softNavEntry.startTime ? softNavEntry.startTime : 0; + if ( + entry.navigationId && + entry.navigationId !== metric.navigationId && + softNavEntryStartTime > metricNavStartTime + ) { + if (!reportedMetric && metric.value > 0) report(true); + initNewINPMetric('soft-navigation', entry.navigationId); + report = bindReporter( + onReport, + metric, + INPThresholds, + opts!.reportAllChanges, + ); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } } }); }; diff --git a/src/onLCP.ts b/src/onLCP.ts index 9dbd79af..014cc59b 100644 --- a/src/onLCP.ts +++ b/src/onLCP.ts @@ -19,19 +19,23 @@ import {bindReporter} from './lib/bindReporter.js'; import {doubleRAF} from './lib/doubleRAF.js'; import {getActivationStart} from './lib/getActivationStart.js'; import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js'; +import {getNavigationEntry} from './lib/getNavigationEntry.js'; import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; -import {runOnce} from './lib/runOnce.js'; +import {getSoftNavigationEntry, softNavs} from './lib/softNavs.js'; import {whenActivated} from './lib/whenActivated.js'; import {whenIdle} from './lib/whenIdle.js'; -import {LCPMetric, MetricRatingThresholds, ReportOpts} from './types.js'; +import { + LCPMetric, + Metric, + MetricRatingThresholds, + ReportOpts, +} from './types.js'; /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; -const reportedMetricIDs: Record = {}; - /** * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and * calls the `callback` function once the value is ready (along with the @@ -48,37 +52,137 @@ export const onLCP = ( opts?: ReportOpts, ) => { // Set defaults + let reportedMetric = false; opts = opts || {}; + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; + let finalizeNavId = ''; whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); + let visibilityWatcher = getVisibilityWatcher(); let metric = initMetric('LCP'); let report: ReturnType; - const handleEntries = (entries: LCPMetric['entries']) => { - // If reportAllChanges is set then call this function for each entry, - // otherwise only consider the last one. - if (!opts!.reportAllChanges) { - entries = entries.slice(-1); + const initNewLCPMetric = ( + navigation?: Metric['navigationType'], + navigationId?: string, + ) => { + metric = initMetric('LCP', 0, navigation, navigationId); + report = bindReporter( + onReport, + metric, + LCPThresholds, + opts!.reportAllChanges, + ); + reportedMetric = false; + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = + softNavEntry && softNavEntry.startTime ? softNavEntry.startTime : 0; } + addInputListeners(); + }; + const handleEntries = (entries: LCPMetric['entries']) => { entries.forEach((entry) => { - // Only report if the page wasn't hidden prior to LCP. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { - // The startTime attribute returns the value of the renderTime if it is - // not 0, and the value of the loadTime otherwise. The activationStart - // reference is used because LCP should be relative to page activation - // rather than navigation start if the page was prerendered. But in cases - // where `activationStart` occurs after the LCP, this time should be - // clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); - metric.entries = [entry]; - report(); + if (entry) { + if ( + softNavsEnabled && + entry.navigationId && + entry.navigationId !== metric.navigationId + ) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final LCP and reinitialize the + // metric. + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + let value = 0; + if (!entry.navigationId || entry.navigationId === hardNavId) { + // The startTime attribute returns the value of the renderTime if it is + // not 0, and the value of the loadTime otherwise. The activationStart + // reference is used because LCP should be relative to page activation + // rather than navigation start if the page was prerendered. But in cases + // where `activationStart` occurs after the LCP, this time should be + // clamped at 0. + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = + softNavEntry && softNavEntry.startTime + ? softNavEntry.startTime + : 0; + value = Math.max(entry.startTime - softNavEntryStartTime, 0); + } + + // Only report if the page wasn't hidden prior to LCP. + // We do allow soft navs to be reported, even if hard nav was not. + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + metric.value = value; + metric.entries = [entry]; + metric.navigationId = entry.navigationId || hardNavId; + report(); + } } }); }; - const po = observe('largest-contentful-paint', handleEntries); + const finalizeLCPs = () => { + removeInputListeners(); + if (!reportedMetric) { + handleEntries(po!.takeRecords() as LCPMetric['entries']); + if (!softNavsEnabled) po!.disconnect(); + // As the clicks are handled when idle, check if the current metric was + // for the reported NavId and only if so, then report. + if (metric.navigationId === finalizeNavId) { + reportedMetric = true; + report(true); + } + } + }; + + const addInputListeners = () => { + ['keydown', 'click'].forEach((type) => { + // Stop listening after input. Note: while scrolling is an input that + // stops LCP observation, it's unreliable since it can be programmatically + // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 + addEventListener(type, () => handleInput(), true); + }); + }; + + const removeInputListeners = () => { + ['keydown', 'click'].forEach((type) => { + // Remove event listeners as no longer required + removeEventListener(type, () => handleInput(), true); + }); + }; + + const handleInput = () => { + // Since we only finalize whenIdle, we only want to finalize the LCPs + // for the current navigationId at the time of the input and not any + // others that came after, and before it was idle. So note the current + // metric.navigationId. + finalizeNavId = metric.navigationId; + // Wrap in a setTimeout so the callback is run in a separate task + // to avoid extending the keyboard/click handler to reduce INP impact + // https://github.com/GoogleChrome/web-vitals/issues/383 + whenIdle(finalizeLCPs); + }; + + const handleHidden = () => { + // Finalise the current navigationId metric. + finalizeNavId = metric.navigationId; + // Wrap in a setTimeout so the callback is run in a separate task + // to avoid extending the keyboard/click handler to reduce INP impact + // https://github.com/GoogleChrome/web-vitals/issues/383 + finalizeLCPs(); + }; + + const po = observe('largest-contentful-paint', handleEntries, opts); if (po) { report = bindReporter( @@ -88,44 +192,52 @@ export const onLCP = ( opts!.reportAllChanges, ); - const stopListening = runOnce(() => { - if (!reportedMetricIDs[metric.id]) { - handleEntries(po!.takeRecords() as LCPMetric['entries']); - po!.disconnect(); - reportedMetricIDs[metric.id] = true; - report(true); - } - }); - - // Stop listening after input. Note: while scrolling is an input that - // stops LCP observation, it's unreliable since it can be programmatically - // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 - ['keydown', 'click'].forEach((type) => { - // Wrap in a setTimeout so the callback is run in a separate task - // to avoid extending the keyboard/click handler to reduce INP impact - // https://github.com/GoogleChrome/web-vitals/issues/383 - addEventListener(type, () => whenIdle(stopListening), true); - }); + addInputListeners(); - onHidden(stopListening); + onHidden(handleHidden); // Only report after a bfcache restore if the `PerformanceObserver` // successfully registered. onBFCacheRestore((event) => { - metric = initMetric('LCP'); - report = bindReporter( - onReport, - metric, - LCPThresholds, - opts!.reportAllChanges, - ); + initNewLCPMetric('back-forward-cache', metric.navigationId); doubleRAF(() => { metric.value = performance.now() - event.timeStamp; - reportedMetricIDs[metric.id] = true; + reportedMetric = true; report(true); }); }); + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach((entry) => { + const softNavEntry = entry.navigationId + ? getSoftNavigationEntry(entry.navigationId) + : null; + if ( + entry.navigationId && + entry.navigationId !== metric.navigationId && + softNavEntry && + (softNavEntry.startTime || 0) > metricNavStartTime + ) { + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } } }); }; diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 8167a568..e579f0f1 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -15,11 +15,13 @@ */ import {bindReporter} from './lib/bindReporter.js'; -import {initMetric} from './lib/initMetric.js'; -import {onBFCacheRestore} from './lib/bfcache.js'; import {getNavigationEntry} from './lib/getNavigationEntry.js'; import {MetricRatingThresholds, ReportOpts, TTFBMetric} from './types.js'; import {getActivationStart} from './lib/getActivationStart.js'; +import {initMetric} from './lib/initMetric.js'; +import {observe} from './lib/observe.js'; +import {onBFCacheRestore} from './lib/bfcache.js'; +import {softNavs} from './lib/softNavs.js'; import {whenActivated} from './lib/whenActivated.js'; /** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */ @@ -61,6 +63,7 @@ export const onTTFB = ( ) => { // Set defaults opts = opts || {}; + const softNavsEnabled = softNavs(opts); let metric = initMetric('TTFB'); let report = bindReporter( @@ -71,25 +74,27 @@ export const onTTFB = ( ); whenReady(() => { - const navigationEntry = getNavigationEntry(); - - if (navigationEntry) { + const hardNavEntry = getNavigationEntry(); + if (hardNavEntry) { + const responseStart = hardNavEntry.responseStart; // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max( - navigationEntry.responseStart - getActivationStart(), - 0, - ); + metric.value = Math.max(responseStart - getActivationStart(), 0); - metric.entries = [navigationEntry]; + metric.entries = [hardNavEntry]; report(true); // Only report TTFB after bfcache restores if a `navigation` entry // was reported for the initial load. onBFCacheRestore(() => { - metric = initMetric('TTFB', 0); + metric = initMetric( + 'TTFB', + 0, + 'back-forward-cache', + metric.navigationId, + ); report = bindReporter( onReport, metric, @@ -99,6 +104,32 @@ export const onTTFB = ( report(true); }); + + // Listen for soft-navigation entries and emit a dummy 0 TTFB entry + const reportSoftNavTTFBs = (entries: SoftNavigationEntry[]) => { + entries.forEach((entry) => { + if (entry.navigationId) { + metric = initMetric( + 'TTFB', + 0, + 'soft-navigation', + entry.navigationId, + ); + metric.entries = [entry]; + report = bindReporter( + onReport, + metric, + TTFBThresholds, + opts!.reportAllChanges, + ); + report(true); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', reportSoftNavTTFBs, opts); + } } }); }; diff --git a/src/types.ts b/src/types.ts index 92c44c63..c94f172f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ interface PerformanceEntryMap { navigation: PerformanceNavigationTiming; resource: PerformanceResourceTiming; paint: PerformancePaintTiming; + 'soft-navigation': SoftNavigationEntry; } // Update built-in types to be more accurate. @@ -49,20 +50,33 @@ declare global { ): PerformanceEntryMap[K][]; } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline + interface PerformancePaintTiming extends PerformanceEntry { + navigationId?: string; + } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline interface PerformanceObserverInit { durationThreshold?: number; + includeSoftNavigationObservations?: boolean; } // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { activationStart?: number; + navigationId?: string; + } + + // https://github.com/WICG/soft-navigations + interface SoftNavigationEntry extends PerformanceEntry { + navigationId?: string; } // https://wicg.github.io/event-timing/#sec-performance-event-timing interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; interactionId: number; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution @@ -70,6 +84,7 @@ declare global { node?: Node; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift @@ -77,6 +92,7 @@ declare global { value: number; sources: LayoutShiftAttribution[]; hadRecentInput: boolean; + navigationId?: string; } // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface @@ -87,6 +103,7 @@ declare global { readonly id: string; readonly url: string; readonly element: Element | null; + navigationId?: string; } // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming diff --git a/src/types/base.ts b/src/types/base.ts index f883affc..15cd9168 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -73,6 +73,7 @@ export interface Metric { * - 'prerender': for pages that were prerendered. * - 'restore': for pages that were discarded by the browser and then * restored by the user. + * - 'soft-navigation': for soft navigations. */ navigationType: | 'navigate' @@ -80,7 +81,16 @@ export interface Metric { | 'back-forward' | 'back-forward-cache' | 'prerender' - | 'restore'; + | 'restore' + | 'soft-navigation'; + + /** + * The navigationId the metric happened for. This is particularly relevent for soft navigations where + * the metric may be reported for a previous URL. + * + * navigationIds are UUID strings. + */ + navigationId: string; } /** The union of supported metric types. */ @@ -128,6 +138,7 @@ export interface ReportCallback { export interface ReportOpts { reportAllChanges?: boolean; durationThreshold?: number; + reportSoftNavs?: boolean; } /** diff --git a/src/types/fcp.ts b/src/types/fcp.ts index ef599b34..77526592 100644 --- a/src/types/fcp.ts +++ b/src/types/fcp.ts @@ -52,9 +52,9 @@ export interface FCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /** diff --git a/src/types/fid.ts b/src/types/fid.ts index 5b2dcba3..6106ce1c 100644 --- a/src/types/fid.ts +++ b/src/types/fid.ts @@ -15,13 +15,15 @@ */ import type {LoadState, Metric} from './base.js'; +import {FirstInputPolyfillEntry} from './polyfills.js'; /** * An FID-specific version of the Metric object. */ export interface FIDMetric extends Metric { name: 'FID'; - entries: PerformanceEventTiming[]; + // Polyfill is still used for bfcache restore and soft navs. + entries: (PerformanceEventTiming | FirstInputPolyfillEntry)[]; } /** @@ -46,8 +48,9 @@ export interface FIDAttribution { eventType: string; /** * The `PerformanceEventTiming` entry corresponding to FID. + * Polyfill is still used for bfcache restore and soft navs. */ - eventEntry: PerformanceEventTiming; + eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry; /** * The loading state of the document at the time when the first interaction * occurred (see `LoadState` for details). If the first interaction occurred diff --git a/src/types/lcp.ts b/src/types/lcp.ts index 4761fdd1..a358a7d0 100644 --- a/src/types/lcp.ts +++ b/src/types/lcp.ts @@ -66,9 +66,9 @@ export interface LCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. diff --git a/src/types/polyfills.ts b/src/types/polyfills.ts index 14bd5aea..b4f7dd9d 100644 --- a/src/types/polyfills.ts +++ b/src/types/polyfills.ts @@ -17,8 +17,10 @@ export type FirstInputPolyfillEntry = Omit< PerformanceEventTiming, 'processingEnd' ->; +> & { + navigationId: PerformanceNavigationTiming['navigationId']; +}; export interface FirstInputPolyfillCallback { - (entry: FirstInputPolyfillEntry): void; + (entries: [FirstInputPolyfillEntry]): void; } diff --git a/src/types/ttfb.ts b/src/types/ttfb.ts index 3559084d..0007062f 100644 --- a/src/types/ttfb.ts +++ b/src/types/ttfb.ts @@ -21,7 +21,7 @@ import type {Metric} from './base.js'; */ export interface TTFBMetric extends Metric { name: 'TTFB'; - entries: PerformanceNavigationTiming[]; + entries: PerformanceNavigationTiming[] | SoftNavigationEntry[]; } /** @@ -65,9 +65,9 @@ export interface TTFBAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for - * example: navigationEntry?.serverTiming + * example: navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /**