Skip to content

feat(plugin-web-vitals-browser): add reportSoftNav option for soft navigation reporting - #1965

Draft
daniel-graham-amplitude wants to merge 2 commits into
mainfrom
feat/web-vitals-soft-nav
Draft

feat(plugin-web-vitals-browser): add reportSoftNav option for soft navigation reporting#1965
daniel-graham-amplitude wants to merge 2 commits into
mainfrom
feat/web-vitals-soft-nav

Conversation

@daniel-graham-amplitude

@daniel-graham-amplitude daniel-graham-amplitude commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a reportSoftNav option to a new WebVitalsOptions type, and reports web vitals per soft navigation when it is enabled.

autocapture.webVitals now accepts an options object in addition to a boolean:

amplitude.init(API_KEY, {
  autocapture: {
    webVitals: { reportSoftNav: true },
  },
});

and the plugin takes the same options directly: webVitalsPlugin({ reportSoftNav: true }).

Default behavior is unchanged. reportSoftNav defaults to false, and webVitals: true keeps producing exactly the same single [Amplitude] Web Vitals event with the same properties as before.

What changes when it's on

The plugin passes reportSoftNavs: true to each of onLCP/onFCP/onINP/onCLS/onTTFB, so web-vitals measures LCP, FCP, INP, CLS and TTFB per soft navigation instead of only for the initial page load. The plugin then:

  • Buckets metrics by metric.navigationId and sends one [Amplitude] Web Vitals event per navigation.
  • Takes the page properties from metric.navigationURL rather than the current URL — a navigation's metrics can be reported after the next navigation has already begun, so the current URL is the wrong one.
  • Reports navigationStart relative to the navigation being measured (performance.timeOrigin + metric.navigationStartTime) and includes navigationId on each metric. Metrics for a soft navigation carry navigationType: 'soft-navigation', so they're distinguishable downstream.
  • Sends a navigation's event once a newer navigation starts reporting metrics, deferred by 1s (SOFT_NAV_FLUSH_DELAY_MS) so metrics finalized just after the navigation boundary still land in the right event. The current navigation is still sent on visibilitychangehidden.
  • Keeps the visibilitychange listener installed (today it removes itself after firing once), since the page can be hidden and shown again with more navigations to report. Events with no metrics are not sent.

In browsers without the Soft Navigations API, every metric shares the hard navigation's navigationId, so behavior falls back to a single event — same as today.

Dependency bump

web-vitals 5.1.0 → 6.2.1. Soft navigation support (ReportOpts.reportSoftNavs, Metric.navigationId / navigationURL / navigationStartTime) landed in v6; there is no way to do this on 5.x. Same package layout and same call signatures for the five onX functions we use.

One wrinkle worth a look: web-vitals@6's Soft Navigations types reference DOM types that don't exist in the TypeScript 4.9 DOM lib (NavigationType), so skipLibCheck is set for this package (there's precedent in the react-native packages). The alternative is a repo-wide TypeScript upgrade, which felt out of scope here.

Open questions for review

  • Flush heuristic. The 1s grace period before sending a superseded navigation is a heuristic. Alternatives: send immediately, or hold everything until visibilitychange. Happy to change it.
  • navigationId on the metric payload. Currently only added when reportSoftNav is on, to keep the default event shape byte-identical. Worth always including?
  • Remote config. AutocaptureOptionsRemoteConfig extends AutocaptureOptions, so the object form flows through remote config without extra work. No transform was added — let me know if this should be gated differently server-side.

Testing

  • plugin-web-vitals-browser: 20 tests, 100% statements/branches/functions/lines. Covers the unchanged default path, opting in per metric, per-navigation events, late-arriving metrics for a superseded navigation, several soft navigations in quick succession, repeated hides, teardown with a pending flush, and unparseable navigation URLs.
  • analytics-browser: added coverage for isWebVitalsEnabled with the object form and for the new getWebVitalsConfig, plus a browser-client test asserting the options reach the plugin. Full suite passes at 100% coverage.
  • analytics-core: full suite passes at 100% coverage.
  • Manual: added test-server/browser-sdk/web-vitals-soft-nav.html, which pushes history entries and paints new content so soft navigations are actually detected. Needs Chromium 151+ to exercise the new path — I have not yet run it against a browser with Soft Navigations support, which is part of why this is a draft.

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No — autocapture.webVitals widens from boolean to boolean | WebVitalsOptions, and the default event is unchanged. The web-vitals major bump is an internal dependency of the plugin.

Note

Medium Risk
Default web vitals behavior is unchanged, but the plugin’s event timing/shape changes when reportSoftNav is enabled and it depends on a major web-vitals bump.

Overview
Adds WebVitalsOptions (starting with reportSoftNav) and widens autocapture.webVitals to boolean | WebVitalsOptions. The browser SDK forwards object config via getWebVitalsConfig into webVitalsPlugin(options); webVitals: true still passes no options and behavior stays the same.

With reportSoftNav: true, the plugin upgrades web-vitals to v6, opts into soft-navigation measurement, buckets metrics by navigation, attaches page properties from navigationURL, and emits one [Amplitude] Web Vitals event per navigation (superseded navigations flush after a 1s delay; the active navigation still flushes on hide). The visibility listener stays registered for repeated hide/show cycles when soft nav is on.

Docs, unit/integration tests, and a manual web-vitals-soft-nav.html test page are included; the plugin enables skipLibCheck for v6 DOM types on TS 4.9.

Reviewed by Cursor Bugbot for commit 9a23983. Bugbot is set up for automated code reviews on this repo. Configure here.

…vigation reporting

Adds `WebVitalsOptions` with a `reportSoftNav` flag, wired through
`autocapture.webVitals`, which now accepts an options object as well as a
boolean.

When enabled, the plugin opts in to web-vitals' `reportSoftNavs` and collects
LCP/FCP/INP/CLS/TTFB per navigation rather than only for the initial page load,
sending one `[Amplitude] Web Vitals` event per navigation with the page
properties of the URL its metrics belong to.

Requires bumping web-vitals from 5.1.0 to 6.2.1, which is where soft navigation
support landed.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 65.71 KB (+2.15% 🔺)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 135.32 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 220.08 KB (+0.61% 🔺)
@amplitude/element-selector (gzipped esm) 3.4 KB (0%)

@daniel-graham-amplitude

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing navigationId on metric payload
    • processMetric now accepts reportSoftNavs and writes navigationId onto the metric payload only when that flag is on, matching the documented event shape.

Create PR

Or push these changes by commenting:

@cursor push d4418ea72b
Preview (d4418ea72b)
diff --git a/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts b/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts
--- a/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts
+++ b/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts
@@ -21,6 +21,7 @@
   id: string;
   timestamp: number;
   navigationStart: number;
+  navigationId?: Metric['navigationId'];
 };
 
 type WebVitalsMetricProperty =
@@ -60,7 +61,7 @@
   return performance.timeOrigin + startTime;
 }
 
-function processMetric(metric: Metric): WebVitalsMetricPayload {
+function processMetric(metric: Metric, reportSoftNavs: boolean): WebVitalsMetricPayload {
   return {
     value: metric.value,
     rating: metric.rating,
@@ -71,6 +72,8 @@
     // A soft navigation's metrics are measured from the start of that navigation rather than from
     // the document's time origin. `navigationStartTime` is 0 for the initial page load.
     navigationStart: Math.floor(performance.timeOrigin + /* istanbul ignore next */ (metric.navigationStartTime || 0)),
+    // Only included when reporting soft navigations so the default event stays byte-identical.
+    ...(reportSoftNavs && { navigationId: metric.navigationId }),
   };
 }
 

diff --git a/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts b/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts
--- a/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts
+++ b/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts
@@ -202,6 +202,7 @@
     expect(eventObject['[Amplitude] INP']).toMatchObject(expectedMetric);
     expect(eventObject['[Amplitude] CLS']).toMatchObject(expectedMetric);
     expect(eventObject['[Amplitude] TTFB']).toMatchObject(expectedMetric);
+    expect(eventObject['[Amplitude] LCP']).not.toHaveProperty('navigationId');
 
     expect(eventObject).toMatchObject({
       '[Amplitude] Page Domain': 'www.example.com',
@@ -297,6 +298,7 @@
         navigationType: 'soft-navigation',
         // performance.timeOrigin (1000) + navigationStartTime (500)
         navigationStart: 1500,
+        navigationId: 4,
       });
     });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 9a23983. Configure here.

// the document's time origin. `navigationStartTime` is 0 for the initial page load.
navigationStart: Math.floor(performance.timeOrigin + /* istanbul ignore next */ (metric.navigationStartTime || 0)),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing navigationId on metric payload

Medium Severity

processMetric is called with reportSoftNavs, but the function only accepts the metric and never writes navigationId onto WebVitalsMetricPayload. Soft-nav events therefore omit the per-navigation identifier this change describes as present when reportSoftNav is on.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9a23983. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant