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 14 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
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,67 @@ _**Note:** the first time the `callback` function is called, its `value` and `de

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,
onFID,
onLCP,
} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module';

onCLS(console.log, {reportSoftNavs: true});
onFID(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,
onFID,
onLCP,
} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module';

onCLS(doTraditionalProcessing);
onFID(doTraditionalProcessing);
onLCP(doTraditionalProcessing);

onCLS(doSoftNavProcessing, {reportSoftNavs: true});
onFID(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 @@ -724,7 +785,14 @@ interface Metric {
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore';
| 'restore'
| 'soft-navigation';

/**
* The URL the metric happened for. This is particularly relevent for soft navigations where
* the metric may be reported for a previous URL.
*/
pageUrl: string;
}
```

Expand Down Expand Up @@ -784,6 +852,7 @@ Metric-specific subclasses:
interface ReportOpts {
reportAllChanges?: boolean;
durationThreshold?: number;
reportSoftNavs?: boolean;
}
```

Expand Down
12 changes: 10 additions & 2 deletions src/lib/initMetric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ import {getActivationStart} from './getActivationStart.js';
import {getNavigationEntry} from './getNavigationEntry.js';
import {Metric} from '../types.js';

export const initMetric = (name: Metric['name'], value?: number): Metric => {
export const initMetric = (
name: Metric['name'],
value?: number,
navigation?: Metric['navigationType']
): Metric => {
const navEntry = getNavigationEntry();
let navigationType: Metric['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) {
if (document.prerendering || getActivationStart() > 0) {
Expand All @@ -47,5 +54,6 @@ export const initMetric = (name: Metric['name'], value?: number): Metric => {
entries: [],
id: generateUniqueID(),
navigationType,
pageUrl: window.location.href,
};
};
1 change: 1 addition & 0 deletions src/lib/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface PerformanceEntryMap {
'first-input': PerformanceEventTiming[] | FirstInputPolyfillEntry[];
'navigation': PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[];
'resource': PerformanceResourceTiming[];
'soft-navigation': SoftNavigationEntry[];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/lib/polyfills/firstInputPolyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const reportFirstInputDelayIfRecordedAndValid = () => {
processingStart: firstInputEvent!.timeStamp + firstInputDelay,
} as FirstInputPolyfillEntry;
callbacks.forEach(function (callback) {
callback(entry);
callback([entry]);
});
callbacks = [];
}
Expand Down
26 changes: 26 additions & 0 deletions src/lib/softNavs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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';

let softNavsEnabled: boolean | undefined;

export const softNavs = (opts?: ReportOpts) => {
if (typeof softNavsEnabled !== 'undefined') return softNavsEnabled;
return (softNavsEnabled =
PerformanceObserver.supportedEntryTypes.includes('soft-navigation') &&
opts?.reportSoftNavs);
};
73 changes: 72 additions & 1 deletion src/onCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {bindReporter} from './lib/bindReporter.js';
import {doubleRAF} from './lib/doubleRAF.js';
import {onHidden} from './lib/onHidden.js';
import {runOnce} from './lib/runOnce.js';
import {softNavs} from './lib/softNavs.js';
import {onFCP} from './onFCP.js';
import {CLSMetric, CLSReportCallback, ReportOpts} from './types.js';
import {CLSMetric, CLSReportCallback, Metric, ReportOpts} from './types.js';

/**
* Calculates the [CLS](https://web.dev/cls/) value for the current page and
Expand All @@ -48,6 +49,8 @@ import {CLSMetric, CLSReportCallback, ReportOpts} from './types.js';
export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
// Set defaults
opts = opts || {};
let currentNav = 1;
const softNavsEnabled = softNavs(opts);

// 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.
Expand All @@ -62,9 +65,52 @@ export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];

const initNewCLSMetric = (navigation?: Metric['navigationType']) => {
metric = initMetric('CLS', 0, navigation);
report = bindReporter(
onReport,
metric,
thresholds,
opts!.reportAllChanges
);
sessionValue = 0;
sessionEntries = [];
};

// const handleEntries = (entries: Metric['entries']) => {
const handleEntries = (entries: LayoutShift[]) => {
let pageUrl = '';
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
entries.forEach((entry) => {
if (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a cleaner way to do it is to have the helper class that calls handleEntries automatically split entries based on navigationID?

Then, you dont' need to do this once per entry.

Also, that helper that does the split could call something like "finalizeAndReset", so we wouldn't need to do these tests here at all. The metric specific handlers would just get a URL and maintain a score...

WDYT?

Copy link
Member Author

@tunetheweb tunetheweb Jan 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While that may look like cleaner code, I wonder about the performance implications of splitting up the array like that versus doing this check every time? In effect you’d need to do that check anyway for each entry AND copy the entries to a new array. Which sounds like a net negative to me.

WDYT?

softNavsEnabled &&
entry.navigationId &&
entry.navigationId > currentNav
) {
// If we've a pageUrl, then we've already done some updates to the values.
// update the Metric.
if (pageUrl != '') {
// 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;
metric.pageUrl = pageUrl;
}
}
report(true);
initNewCLSMetric('soft-navigation');
currentNav = entry.navigationId;
}

if (entry.navigationId === 1 || !entry.navigationId) {
pageUrl = performance.getEntriesByType('navigation')[0].name;
} else {
pageUrl =
performance.getEntriesByType('soft-navigation')[
entry.navigationId - 2
]?.name;
}

// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
Expand Down Expand Up @@ -93,6 +139,7 @@ export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;
metric.pageUrl = pageUrl;
report();
}
};
Expand Down Expand Up @@ -126,6 +173,30 @@ export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
doubleRAF(() => report());
});

const reportSoftNavCLS = (entries: SoftNavigationEntry[]) => {
entries.forEach((entry) => {
if (entry.navigationId) {
report(true);
metric = initMetric('CLS', 0, 'soft-navigation');
metric.pageUrl =
performance.getEntriesByType('soft-navigation')[
entry.navigationId - 2
]?.name;
report = bindReporter(
onReport,
metric,
thresholds,
opts!.reportAllChanges
);
currentNav = entry.navigationId;
}
});
};

if (softNavs(opts)) {
observe('soft-navigation', reportSoftNavCLS);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followup from comment above... I think we should just automatically do this from inside the observe() helper, shared by all metrics

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all metrics need the soft-navigation observer (FCP and FID don’t for example as they can finalise immediately).

For those that do need this to finalise metrics that may not be finalised before then, there are different steps to make so it’s not generic across all the metrics. We could pass in a callback function to handle that, but that’s exactly what this currently does.

Also currently the observe function is for a single observer with a call back on what to do with that. I’m not sure it’s right to change that to also call another observer for some metrics, with a different callback function.

But maybe I’m just not seeing the way to do this? If you have a look at some of the other metrics and still think there’s an easier/cleaner way to do this, then definitely open to it, if you could give me a little nudge as to how you think it could be structure.

}

// 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.
Expand Down
47 changes: 42 additions & 5 deletions src/onFCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {getActivationStart} from './lib/getActivationStart.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe} from './lib/observe.js';
import {softNavs} from './lib/softNavs.js';
import {whenActivated} from './lib/whenActivated.js';
import {FCPMetric, FCPReportCallback, ReportOpts} from './types.js';
import {FCPMetric, Metric, FCPReportCallback, ReportOpts} from './types.js';

/**
* Calculates the [FCP](https://web.dev/fcp/) value for the current page and
Expand All @@ -33,6 +34,7 @@ import {FCPMetric, FCPReportCallback, ReportOpts} from './types.js';
export const onFCP = (onReport: FCPReportCallback, opts?: ReportOpts) => {
// Set defaults
opts = opts || {};
const softNavsEnabled = softNavs(opts);

whenActivated(() => {
// https://web.dev/fcp/#what-is-a-good-fcp-score
Expand All @@ -42,19 +44,54 @@ export const onFCP = (onReport: FCPReportCallback, opts?: ReportOpts) => {
let metric = initMetric('FCP');
let report: ReturnType<typeof bindReporter>;

const initNewFCPMetric = (navigation?: Metric['navigationType']) => {
metric = initMetric('FCP', 0, navigation);
report = bindReporter(
onReport,
metric,
thresholds,
opts!.reportAllChanges
);
};

const handleEntries = (entries: FCPMetric['entries']) => {
(entries as PerformancePaintTiming[]).forEach((entry) => {
if (entry.name === 'first-contentful-paint') {
po!.disconnect();
if (!softNavsEnabled) {
po!.disconnect();
} else if (entry.navigationId) {
initNewFCPMetric('soft-navigation');
}

// Only report if the page wasn't hidden prior to the first paint.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
let value = 0;
let pageUrl = '';

if (entry.navigationId === 1 || !entry.navigationId) {
// 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);
pageUrl = performance.getEntriesByType('navigation')[0].name;
} else {
const navEntry =
performance.getEntriesByType('soft-navigation')[
entry.navigationId - 2
];
const navStartTime = navEntry?.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 - navStartTime, 0);
pageUrl = navEntry?.name;
}

// Only report if the page wasn't hidden prior to FCP.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries.push(entry);
metric.pageUrl = pageUrl;
// FCP should only be reported once so can report right
report(true);
}
}
Expand Down
Loading