diff --git a/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts index 15bc9a2f401b..0af2a016396f 100644 --- a/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils'; // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction // that triggered it, so it only holds while the navigation span is started before the interaction's @@ -15,6 +15,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea await page.goto('/'); await page.locator('#navLink').click(); + await waitForSoftNavigation(page); + // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing // is reported for it until the page goes away. await hidePage(page); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts index 0b9ff087dfde..b35b9ea1c498 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils'; // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction // that triggered it, so it only holds while the navigation span is started before the interaction's @@ -17,6 +17,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea await page.goto('/'); await page.locator('#navigation').click(); + await waitForSoftNavigation(page); + // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing // is reported for it until the page goes away. await hidePage(page); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts index a508443ca3f0..ee333b3c8820 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils'; // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction // that triggered it, so it only holds while the navigation span is started before the interaction's @@ -16,6 +16,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea await page.goto('/'); await page.click('#navigation'); + await waitForSoftNavigation(page); + // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing // is reported for it until the page goes away. await hidePage(page); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts index d9d388ec6657..f451b532c597 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils'; // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction // that triggered it, so it only holds while the navigation span is started before the interaction's @@ -15,6 +15,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea await page.goto('/'); await page.locator('#navLink').click(); + await waitForSoftNavigation(page); + // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing // is reported for it until the page goes away. await hidePage(page); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 3da52584017a..9b245bd74ee5 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -32,7 +32,7 @@ export type { OutputScanOptions } from './build-output'; export { assertBundlerInstrumentation } from './bundler-instrumentation'; export type { InstrumentationFixture } from './bundler-instrumentation'; -export { hidePage } from './page'; +export { hidePage, waitForSoftNavigation } from './page'; export { getPlaywrightConfig } from './playwright-config'; export { getRuntime } from './runtime'; export type { Runtime } from './runtime'; diff --git a/dev-packages/test-utils/src/page.ts b/dev-packages/test-utils/src/page.ts index 83344967bcd4..450165f13f4a 100644 --- a/dev-packages/test-utils/src/page.ts +++ b/dev-packages/test-utils/src/page.ts @@ -76,3 +76,32 @@ export async function hidePage(page: Page): Promise { }); /* oxlint-enable no-restricted-globals */ } + +/** + * Waits until the browser has delivered the `soft-navigation` entry for a client-side navigation. + * + * The entry only arrives once the new route has painted, which can be after the click resolves. + * web-vitals starts the soft navigation's metrics when it sees the entry, so hiding the page before + * that reports the vitals for the previous navigation, and those of the soft navigation are never + * finalized. + */ +export async function waitForSoftNavigation(page: Page): Promise { + /* oxlint-disable no-restricted-globals */ + await page.evaluate(() => { + return new Promise(resolve => { + if (!PerformanceObserver.supportedEntryTypes.includes('soft-navigation')) { + resolve(); + return; + } + + // The SDK's observer was registered first, so it is notified first. Resolving from a task after + // ours runs lets it handle the entry before the caller continues. + const observer = new PerformanceObserver(() => { + observer.disconnect(); + setTimeout(resolve, 0); + }); + observer.observe({ type: 'soft-navigation', buffered: true }); + }); + }); + /* oxlint-enable no-restricted-globals */ +}