Skip to content

[soft-navs]: onINP bfcache restore reports original hard-nav URL instead of soft-nav URL as navigationURL #734

Description

@Mimori256

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:

  1. Hard navigate to http://localhost (SPA loads)
  2. Perform a soft navigation to /about (metric.navigationId is now the soft nav ID for all metrics)
  3. Hard navigate away (e.g. to http://example.com; page enters bfcache)
  4. Press back (bfcache restore fires pageshow with persisted: true)
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions