Skip to content

⚗️ Add soft navigation LCP tracking for route_change views - #4966

Open
mormubis wants to merge 20 commits into
mainfrom
adrian.delarosa/soft-navigation
Open

⚗️ Add soft navigation LCP tracking for route_change views#4966
mormubis wants to merge 20 commits into
mainfrom
adrian.delarosa/soft-navigation

Conversation

@mormubis

@mormubis mormubis commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Motivation

route_change views never report LCP today. trackInitialViewMetrics only runs for initial_load, so SPAs get zero LCP after the first page. Chrome's Soft Navigation API (stable since Chrome 151, no flag needed on their end) actually solves this now: soft-navigation + interaction-contentful-paint entries let us compute LCP per client-side navigation.

There's an open GitHub issue for this (#2696) and an earlier draft PR (#4154) that only added a boolean is_soft_navigation flag without touching LCP, which isn't really what the issue is asking for. This PR does the actual LCP computation.

Changes

New ExperimentalFeature.SOFT_NAVIGATION flag. When enabled and the browser supports it, route_change views get a trackRouteChangeViewMetrics tracker (same shape as trackInitialViewMetrics) that subscribes to soft-navigation and interaction-contentful-paint entries, correlates them by interactionId, and fills in view.performance.lcp.*. No schema changes, reuses the existing field.

The tricky part was figuring out when to stop listening for the soft-navigation entry. It's per-view and Chrome fires it async, so a view whose interaction never actually produced a soft navigation would keep listening and could steal the next view's entry if I didn't unsubscribe it in time. Left a comment on that.

Test instructions

  1. enableExperimentalFeatures: ['soft_navigation'] in your init config.
  2. Chrome 151+, click something that does a synchronous DOM mutation followed by history.pushState in the same task (that's the heuristic Chrome uses to detect a soft navigation).
  3. Check the resulting route_change view event, view.performance.lcp.timestamp should be populated.
  4. Without the flag, or on any other browser, it stays undefined like before.

Unit tests: yarn test:unit --spec packages/browser-rum-core/src/domain/view/viewMetrics/trackRouteChangeViewMetrics.spec.ts and yarn test:unit --spec packages/browser-rum-core/src/domain/view/trackViews.spec.ts

E2E: yarn test:e2e -g "soft navigation" (Chromium only). Confirmed green in CI across all browser variants.

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file — skipping for now since it's experimental/flag-gated, will document properly once it's stable enough to drop the flag

…kspace app

The nuxt-vue-router-v4-app is a generated app (gitignored, not registered in the
root Yarn workspace). Playwright's webServer config starts every listed server
before any test run, regardless of -g filtering. When Playwright spawned
`yarn dev` in that directory, Yarn 4 traversed up to the workspace root and
failed with "Package for nuxt-vue-router-v4-app@workspace:. not found" because
the package isn't registered there.

Switch to `yarn start` (nuxt preview of the pre-built .output/) which runs the
built app directly via node and does not trigger Yarn workspace resolution.
export interface RumInteractionContentfulPaintTiming {
entryType: RumPerformanceEntryType.INTERACTION_CONTENTFUL_PAINT
interactionId: number
largestContentfulPaint: RumLargestContentfulPaintTiming

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reused RumLargestContentfulPaintTiming here instead of a flat shape. The WICG spec types InteractionContentfulPaint.largestContentfulPaint as the full LargestContentfulPaint interface, not a subset, so this felt more accurate. Also gets toJSON() for free this way.

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 181.27 KiB 182.31 KiB +1.04 KiB +0.57%
Rum Profiler 8.43 KiB 8.43 KiB 0 B 0.00%
Rum Recorder 21.12 KiB 21.12 KiB 0 B 0.00%
Logs 57.50 KiB 57.53 KiB +36 B +0.06%
Rum Salesforce N/A 140.24 KiB N/A N/A N/A
Rum Slim 139.21 KiB 140.24 KiB +1.03 KiB +0.74%
Worker 22.96 KiB 22.96 KiB 0 B 0.00%
Rum Shopify N/A 202.02 KiB N/A N/A N/A
Rum-shopify Profiler N/A 8.43 KiB N/A N/A N/A
Rum-shopify Recorder N/A 3.72 KiB N/A N/A N/A

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Aug 20, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 81.25%
Overall Coverage: 77.23% (+0.01%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: fbb0da9 | Docs | View more details | Give us feedback!

…soft-nav LCP test

- Ran 'yarn format' on 3 files that were failing the format check (never ran
  yarn format locally during development, only yarn lint/typecheck).
- The 'reports LCP' E2E test only skipped on non-chromium browsers, but
  chromium-pinned (Chrome 120) normalizes to browserName 'chromium' too and
  predates the Soft Navigation API (needs Chrome 151+). Switched to
  test.info().project.name for an exact match, following the same pattern
  already used in salesforce.scenario.ts.
- Only buffer pending ICP entries until the soft-navigation entry is known;
  once resolved, applyIcpEntries handles updates live and the buffer was
  never read again, so it was just accumulating for no reason.
- Fix chromium-pinned being silently skipped in the 'does not error on
  browsers without the soft navigation API' test, same browserName vs
  project.name normalization issue already fixed elsewhere. It's a real
  Chromium build without the API, a better fit for this test than relying
  on firefox/webkit alone.
@tunetheweb

Copy link
Copy Markdown

Super excited to see this! Let me know if you have any questions or anything I can do to help.

Reuses the existing loadDelay/loadTime/renderDelay breakdown from
trackLargestContentfulPaint by extracting computeLcpSubParts and
parametrizing its baseline instead of hardcoding TTFB from the
navigation entry.

For a soft nav there's no request/response round trip to measure TTFB
from, so the soft-navigation entry's own startTime is used as the
baseline instead, per web-vitals' own approach (assume TTFB = 0). No
behavior change for the initial-load path, verified by its existing
test suite staying green unchanged.

Addresses the open review comment/discussion with @tunetheweb.
…reference

Kept the one genuinely non-obvious invariant (setViewEnd must unsubscribe
immediately or an ended view could steal the next view's entry). Dropped
the reference to the spec doc's 'Per-view vs global subscription' section,
that doc was removed from the PR earlier so it was a dead pointer.
@mormubis

mormubis commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T10:58:19.057634Z fbb0da9 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90e9abce2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/e2e/scenario/rum/softNavigation.scenario.ts Outdated
…es a soft nav

setViewEnd() only unsubscribed the soft-navigation observer, leaving the ICP
one alive for the full 5-minute stop() window. For a view whose interaction
never produces a soft-navigation entry, softNavEntry never gets set, so the
existing !softNavEntry guard on the ICP buffer push never turns off, and
every ICP entry firing anywhere on the page keeps getting buffered for
nothing. On interaction-heavy SPAs with frequent route changes this adds up
across every unmatched view.

Once setViewEnd() runs with no softNavEntry, we know for certain it never
will (the soft-nav subscription is dead too), so unsubscribe the ICP one
immediately as well.

Added activeObserverCount to mockPerformanceObserver to make the teardown
itself testable -- the JS-visible behavior is identical with or without
this fix, since correlation already short-circuits on a missing
softNavEntry, so the only way to prove the fix is checking the observer
was actually disconnected.

Found by Codex automated review.
…te soft-nav entry

If this view's own transition never produces a soft-navigation entry
(rare) and a later, unrelated interaction on the same view does (e.g. a
query-only history update, which areDifferentLocation() in trackViews.ts
intentionally doesn't treat as a new RUM view), that entry gets attributed
to the wrong tracker.

Considered a real fix (correlate by URL, needs new plumbing since
newView() doesn't receive location today) and a timeout-based mitigation
(bounds the window but doesn't close the race). Both add real complexity
for a compounding edge case Codex itself flagged as P2. Accepting it for
now, feature is experimental/flag-gated, revisit if it shows up in
practice.

Found by Codex automated review.
…ect names

Fixes the same class of bug for the third time in this file (chromium vs
chromium-pinned via browserName, then via project.name, now android).
The android Playwright project uses current Chromium under a different
project name, so the project.name === 'chromium' check missed it, and the
test would have failed there once android is added to CI (it isn't yet).

Asking the browser directly whether it supports the API -- the same check
the SDK itself uses internally (supportPerformanceTimingEvent) -- avoids
needing to know about every current-Chromium project name up front.

Found by Codex automated review.
@mormubis
mormubis marked this pull request as ready for review September 7, 2026 10:49
@mormubis
mormubis requested a review from a team as a code owner September 7, 2026 10:49

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbb0da98e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const lcpEntry = entry.largestContentfulPaint
const resourceUrl = lcpEntry.url || undefined
const largestContentfulPaint: LargestContentfulPaint = {
value: (lcpEntry.startTime - softNavEntry.startTime) as RelativeTime,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject excessively delayed soft-navigation LCPs

issue: When a soft navigation is interrupted by the tab being backgrounded or the device sleeping before its content paints, an ICP entry can arrive much later and this subtraction publishes the entire pause as LCP. The initial-view path explicitly rejects entries at or beyond LCP_MAXIMUM_DELAY to avoid sleep-mode and browser outliers (trackLargestContentfulPaint.ts), but this route path has no equivalent relative-delay check, so opted-in SPAs can send arbitrarily large route-change LCP values and skew aggregates; reject derived values beyond the same limit.

Useful? React with 👍 / 👎.


/**
* Tracks LCP for a `route_change` view via Chrome's Soft Navigation API (gated behind
* ExperimentalFeature.SOFT_NAVIGATION + browser support, see trackViews.ts).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is the idea that this will be default behavior in v8? If so we should leave it in the comments

Comment on lines +77 to +80
expect(routeChangeViews[0].view.performance?.lcp).toBeUndefined()

// No console errors -- automatically validated by test teardown.
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(routeChangeViews[0].view.performance?.lcp).toBeUndefined()
// No console errors -- automatically validated by test teardown.
})
expect(routeChangeViews[0].view.performance?.lcp).toBeUndefined()
})

I think this log is not necesary.

Comment on lines 271 to 285

const { stop: stopInitialViewMetricsTracking, initialViewMetrics } =
loadingType === ViewLoadingType.INITIAL_LOAD
? trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate)
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics }
const {
stop: stopInitialViewMetricsTracking,
initialViewMetrics,
setViewEnd: setRouteChangeViewEnd,
} = loadingType === ViewLoadingType.INITIAL_LOAD
? { ...trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate), setViewEnd: noop }
: loadingType === ViewLoadingType.ROUTE_CHANGE &&
isExperimentalFeatureEnabled(ExperimentalFeature.SOFT_NAVIGATION) &&
supportPerformanceTimingEvent(RumPerformanceEntryType.SOFT_NAVIGATION)
? trackRouteChangeViewMetrics(configuration, scheduleViewUpdate)
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics, setViewEnd: noop }

// Start BFCache-specific metrics when restoring from BFCache
if (loadingType === ViewLoadingType.BF_CACHE) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const { stop: stopInitialViewMetricsTracking, initialViewMetrics } =
loadingType === ViewLoadingType.INITIAL_LOAD
? trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate)
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics }
const {
stop: stopInitialViewMetricsTracking,
initialViewMetrics,
setViewEnd: setRouteChangeViewEnd,
} = loadingType === ViewLoadingType.INITIAL_LOAD
? { ...trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate), setViewEnd: noop }
: loadingType === ViewLoadingType.ROUTE_CHANGE &&
isExperimentalFeatureEnabled(ExperimentalFeature.SOFT_NAVIGATION) &&
supportPerformanceTimingEvent(RumPerformanceEntryType.SOFT_NAVIGATION)
? trackRouteChangeViewMetrics(configuration, scheduleViewUpdate)
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics, setViewEnd: noop }
// Start BFCache-specific metrics when restoring from BFCache
if (loadingType === ViewLoadingType.BF_CACHE) {
let viewMetricsTracking =
loadingType === ViewLoadingType.INITIAL_LOAD
? { ...trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate), setViewEnd: noop }
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics, setViewEnd: noop }
if (
loadingType === ViewLoadingType.ROUTE_CHANGE &&
isExperimentalFeatureEnabled(ExperimentalFeature.SOFT_NAVIGATION) &&
supportPerformanceTimingEvent(RumPerformanceEntryType.SOFT_NAVIGATION)
) {
viewMetricsTracking = trackRouteChangeViewMetrics(configuration, scheduleViewUpdate)
}
const {
stop: stopInitialViewMetricsTracking,
initialViewMetrics,
setViewEnd: setRouteChangeViewEnd,
} = viewMetricsTracking
// Start BFCache-specific metrics when restoring from BFCache
if (loadingType === ViewLoadingType.BF_CACHE) {

What do you think to improve readability?

Comment on lines +38 to +54
createTest('does not report LCP on a route_change view without the experimental feature enabled')
.withRum()
.withBody(NAV_BUTTON)
.run(async ({ intakeRegistry, flushEvents, page, browserName }) => {
test.skip(browserName !== 'chromium', 'Soft navigation API is Chromium-only')

await page.locator('#nav-button').click()
await page.waitForTimeout(100)

await flushEvents()

const routeChangeViews = intakeRegistry.rumViewEvents.filter((v) => v.view.loading_type === 'route_change')
expect(routeChangeViews.length).toBeGreaterThanOrEqual(1)

const lastRouteChange = routeChangeViews[routeChangeViews.length - 1]
expect(lastRouteChange.view.performance?.lcp).toBeUndefined()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe this is tested in the unit tests, no need to add a e2e IMO.

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.

4 participants