Description
When an SPA performs a soft navigation and the user hard-navigates away and returns via bfcache restore, INP navigationURL reports the original hard navigation URL instead of the URL that was active when the page was put into bfcache. CLS and LCP report the correct soft navigation URL.
For instance:
- Hard navigate to
http://localhost (SPA loads)
- Perform a soft navigation to
/about (metric.navigationId is now the soft nav ID for all metrics)
- Hard navigate away (e.g. to
http://example.com; page enters bfcache)
- Press back (bfcache restore fires
pageshow with persisted: true)
- Trigger any interaction (forces INP to report)
Expected: All metrics (CLS, LCP, INP) report http://localhost/about
Actual: Only INP reports http://localhost (the original hard-nav URL)
This affects any SPA using soft navigations with bfcache enabled, which covers virtually all production deployments.
Root cause
In onINP.ts, the onBFCacheRestore callback creates a metric with the correct navigationId via initNewINPMetric, but immediately overwrites both metric and report before doubleRAF fires:
onBFCacheRestore(() => {
interactionManager._resetInteractions();
initNewINPMetric('back-forward-cache', metric.navigationId); // metric A: navigationId = softNavId ✓
doubleRAF(() => report()); // captures `report` by reference, not yet updated
metric = initMetric('INP'); // metric B: navigationId = hardNavId ✗ (overwrites A)
report = bindReporter( // report now points to reporter B
onReport, metric, INPThresholds, opts.reportAllChanges,
);
// doubleRAF fires reporter B, but metric B.value === -1,
// so bindReporter's `if (metric.value >= 0)` guard suppresses the report entirely.
});
By contrast, onCLS correctly captures the reporter value at call time rather than via closure:
onBFCacheRestore(() => {
initNewCLSMetric('back-forward-cache', metric.navigationId);
doubleRAF(report); // value captured at call time ✓
});
Proposed fix
Follow the same pattern as onCLS: capture the report value directly after initNewINPMetric updates it, and remove the subsequent re-initialization that overwrites metric and report with a stale hard-nav context.
onBFCacheRestore(() => {
initNewINPMetric('back-forward-cache', metric.navigationId);
doubleRAF(report); // capture value, not reference
});
After this change:
metric and report remain bound to metric A (soft nav URL) after the callback returns.
- Subsequent interactions accumulate into metric A and are reported with the correct URL.
doubleRAF fires reporter A, which is suppressed if no interactions have occurred yet; the same behavior as CLS.
Description
When an SPA performs a soft navigation and the user hard-navigates away and returns via bfcache restore, INP
navigationURLreports the original hard navigation URL instead of the URL that was active when the page was put into bfcache. CLS and LCP report the correct soft navigation URL.For instance:
http://localhost(SPA loads)/about(metric.navigationIdis now the soft nav ID for all metrics)http://example.com; page enters bfcache)pageshowwithpersisted: true)Expected: All metrics (CLS, LCP, INP) report
http://localhost/aboutActual: Only INP reports
http://localhost(the original hard-nav URL)This affects any SPA using soft navigations with bfcache enabled, which covers virtually all production deployments.
Root cause
In
onINP.ts, theonBFCacheRestorecallback creates a metric with the correctnavigationIdviainitNewINPMetric, but immediately overwrites bothmetricandreportbeforedoubleRAFfires:By contrast,
onCLScorrectly captures the reporter value at call time rather than via closure:Proposed fix
Follow the same pattern as
onCLS: capture thereportvalue directly afterinitNewINPMetricupdates it, and remove the subsequent re-initialization that overwritesmetricandreportwith a stale hard-nav context.After this change:
metricandreportremain bound to metric A (soft nav URL) after the callback returns.doubleRAFfires reporter A, which is suppressed if no interactions have occurred yet; the same behavior as CLS.