Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental Soft Navigation support #308

Draft
wants to merge 65 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
42ccabc
Initial LCP support
tunetheweb Jan 16, 2023
d995891
Add FCP support
tunetheweb Jan 16, 2023
d2fc3ec
Comments
tunetheweb Jan 16, 2023
9b8202d
Support buffered entries
tunetheweb Jan 17, 2023
d2e0ab6
Simplify LCP using performance.getEntriesByType() instead
tunetheweb Jan 19, 2023
97fd350
Bug fix
tunetheweb Jan 19, 2023
03f8448
Fix FCP too
tunetheweb Jan 19, 2023
74b1231
Linting
tunetheweb Jan 19, 2023
50e9ded
Add TTFB support
tunetheweb Jan 19, 2023
dca3482
README updates
tunetheweb Jan 19, 2023
a387712
Clean up Metric
tunetheweb Jan 19, 2023
50d8dc1
Add FID support
tunetheweb Jan 19, 2023
5fa268c
Tidy up
tunetheweb Jan 19, 2023
4f558ee
Add CLS
tunetheweb Jan 19, 2023
01ab0ed
Allow FID to show after hiding for soft navs
tunetheweb Jan 19, 2023
9e038c3
Implement CLS
tunetheweb Jan 19, 2023
d12ce69
Move from pageUrl to navigationId
tunetheweb Jan 20, 2023
9f87d85
Cleanup and fixes
tunetheweb Jan 20, 2023
1700428
Fix attribution
tunetheweb Jan 20, 2023
d0a4f19
Initial INP support
tunetheweb Jan 20, 2023
c5984e5
Finalize LCP on softnav change
tunetheweb Jan 20, 2023
9afc257
Switch LCP to process all entries so reportAllChanges works
tunetheweb Jan 20, 2023
7e316eb
Bug fixes
tunetheweb Jan 20, 2023
ecf82d4
Update package
tunetheweb Feb 3, 2023
f013531
Bump version number
tunetheweb Feb 8, 2023
2d7ea75
Fix onLCP for multiple callbacks
tunetheweb Feb 9, 2023
2e63c81
Update version
tunetheweb Feb 9, 2023
bb49653
Fix FID and FCP navigation type
tunetheweb Feb 9, 2023
60dde0b
Add support includeSoftNavigationObservations
tunetheweb Feb 10, 2023
aa45395
Update version
tunetheweb Feb 10, 2023
b4b28b0
Fix includeSoftNavigationObservations bug
tunetheweb Feb 11, 2023
f381326
Merge branch 'main' into soft-navs
tunetheweb Mar 6, 2023
e11c6b8
Merge branch 'main' into soft-navs
tunetheweb Mar 9, 2023
99d2b9b
Merge branch 'main' into soft-navs
tunetheweb Mar 9, 2023
5cdb8f9
Merge branch 'main' into soft-navs
tunetheweb Apr 4, 2023
090e7b2
Merge branch 'main' into soft-navs
tunetheweb May 29, 2023
39a043a
Merge branch 'main' into soft-navs
tunetheweb Jul 10, 2023
9c1e68c
Support navigationIds that are UUIDs
tunetheweb Jul 10, 2023
5cc8b86
UUID cleanup
tunetheweb Jul 10, 2023
94dd1cc
More clean up
tunetheweb Jul 10, 2023
f659c15
Avoid repeated soft nav lookups
tunetheweb Jul 10, 2023
607041f
More cleanup
tunetheweb Jul 10, 2023
db653ae
Cleanup and comments
tunetheweb Jul 10, 2023
173d87f
Even more cleanup and comments
tunetheweb Jul 11, 2023
71c5927
Fix comments
tunetheweb Jul 11, 2023
9d87a20
No INP unless supported by that browser
tunetheweb Jul 12, 2023
b39d261
Restore comment
tunetheweb Jul 12, 2023
9835574
Remove unnecessary ?
tunetheweb Jul 12, 2023
e373b67
Remove optional chaining
tunetheweb Jul 12, 2023
5de5e89
Merge branch 'main' into soft-navs
tunetheweb Sep 28, 2023
7c00038
Fix attribution for TTFB and LCP
tunetheweb Dec 13, 2023
2dea9a2
Fix FCP and LCP attributions
tunetheweb Dec 13, 2023
1e72319
Fix attribution of reused resource
tunetheweb Dec 13, 2023
42b8e47
Reset visibilitywatcher on soft nav
tunetheweb Dec 15, 2023
5b5c81c
Bump version number
tunetheweb Dec 15, 2023
1af7905
Merge branch 'main' into soft-navs
tunetheweb Dec 28, 2023
3ebf57f
Linting
tunetheweb Dec 28, 2023
5ab847d
Merge branch 'main' into soft-navs
tunetheweb Jan 26, 2024
8b8bd7f
Merge branch 'main' into soft-navs
tunetheweb Mar 19, 2024
44c6e07
Merge branch 'main' into soft-navs
tunetheweb Jul 31, 2024
cd5a2ee
Fix unit tests
tunetheweb Jul 31, 2024
f960498
Fix LCP on inputs for soft navs
tunetheweb Aug 1, 2024
57b751e
Bump version for npm publish
tunetheweb Aug 1, 2024
8a42838
Merge branch 'main' into soft-navs
tunetheweb Aug 4, 2024
a44dd93
Merge branch 'main' into soft-navs
tunetheweb Aug 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -552,14 +613,22 @@ 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'
| 'reload'
| '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;
}
```

Expand Down Expand Up @@ -648,6 +717,7 @@ _See also [Rating Thresholds](#rating-thresholds)._
interface ReportOpts {
reportAllChanges?: boolean;
durationThreshold?: number;
reportSoftNavs?: boolean;
}
```

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 19 additions & 4 deletions src/attribution/onFCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
36 changes: 30 additions & 6 deletions src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -34,17 +36,37 @@ 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 &&
performance
.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,
Expand All @@ -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 = {
Expand Down
11 changes: 7 additions & 4 deletions src/attribution/onTTFB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <PerformanceNavigationTiming>metric.entries[0];

const activationStart = navigationEntry.activationStart || 0;

// Measure from workerStart or fetchStart so any service worker startup
Expand All @@ -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,
);

Expand Down
4 changes: 2 additions & 2 deletions src/lib/getActivationStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
14 changes: 7 additions & 7 deletions src/lib/getLoadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/lib/getVisibilityWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions src/lib/initMetric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,25 @@ import {MetricType} from '../types.js';
export const initMetric = <MetricName extends MetricType['name']>(
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'];
Expand All @@ -53,5 +59,6 @@ export const initMetric = <MetricName extends MetricType['name']>(
entries,
id: generateUniqueID(),
navigationType,
navigationId: navigationId || hardNavId,
};
};
Loading