From 606a4296988c36c23827b6845ae2baa46a0fd90f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 14:46:47 -0400 Subject: [PATCH 1/8] feat(browser): Add bfcacheMetricsIntegration Add an opt-in browser integration that tracks back/forward cache health as metrics: - browser.bfcache.navigation: hit/miss counter by navigation type - browser.bfcache.not_restored: Chromium notRestoredReasons per miss - browser.bfcache.miss.reload.duration: fallback reload cost on a miss Hits are detected via pageshow.persisted; misses via a back_forward navigation entry. Includes unit tests for the notRestoredReasons parser and a Playwright suite for hit/miss detection. --- CHANGELOG.md | 17 ++ .../suites/integrations/bfcache/init.js | 9 + .../suites/integrations/bfcache/page-0.html | 10 + .../suites/integrations/bfcache/subject.js | 1 + .../suites/integrations/bfcache/template.html | 10 + .../suites/integrations/bfcache/test.ts | 84 +++++++++ packages/browser/src/index.ts | 1 + packages/browser/src/integrations/bfcache.ts | 176 ++++++++++++++++++ .../browser/test/integrations/bfcache.test.ts | 112 +++++++++++ 9 files changed, 420 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts create mode 100644 packages/browser/src/integrations/bfcache.ts create mode 100644 packages/browser/test/integrations/bfcache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8190e95a4c4..ac0e95bfb904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(browser): Add `bfcacheMetricsIntegration` to track back/forward cache health** + + The new opt-in `bfcacheMetricsIntegration` emits metrics about browser back/forward cache (bfcache) navigations, so you can + measure how often back-button navigation is instant and what's blocking it. + + ```js + Sentry.init({ + integrations: [Sentry.bfcacheMetricsIntegration()], + }); + ``` + + It emits: + + - `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`) and navigation type. + - `browser.bfcache.not_restored` — a counter of the (Chromium-only) `notRestoredReasons` for a miss. + - `browser.bfcache.reload.duration` — a distribution of how expensive the fallback reload was on a miss. + ## 10.67.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js b/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js new file mode 100644 index 000000000000..dcba07a43a70 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.bfcacheMetricsIntegration()], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html b/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html new file mode 100644 index 000000000000..72ea32047059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html @@ -0,0 +1,10 @@ + + + + + + +

BFCache test - second page

+ Back to first page + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js b/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js new file mode 100644 index 000000000000..1062100d9f8d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js @@ -0,0 +1 @@ +// no-op subject; navigation is driven from the test diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html b/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html new file mode 100644 index 000000000000..114f77b90f41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html @@ -0,0 +1,10 @@ + + + + + + +

BFCache test - first page

+ Go to second page + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts b/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts new file mode 100644 index 000000000000..81ea18092ee2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import type { SerializedMetric } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipMetricsTest, waitForMetrics } from '../../../utils/helpers'; + +function byName(metrics: SerializedMetric[], name: string): SerializedMetric[] { + return metrics.filter(m => m.name === name); +} + +function attr(m: SerializedMetric, key: string): unknown { + return m.attributes?.[key]?.value; +} + +// Note on bfcache under Playwright: +// Playwright launches Chromium with `--disable-back-forward-cache` (and the attached CDP session +// blocks bfcache regardless), so a *real* bfcache restore cannot be reproduced here. We therefore +// drive the hit path by dispatching the same `pageshow` signal the browser would emit on restore, +// which is exactly the contract our integration reacts to. The miss path uses a real back/forward +// navigation, which is always a fresh load in this environment. + +sentryTest('reports outcome:hit when a page is restored from bfcache', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipMetricsTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const metricsPromise = waitForMetrics(page, metrics => + byName(metrics, 'browser.bfcache.navigation').some(m => attr(m, 'browser.bfcache.outcome') === 'hit'), + ); + + // Simulate the browser restoring this page from bfcache. + await page.evaluate(() => { + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + // Metrics are buffered and only flushed when the page is hidden, so flush explicitly. + return (window as { Sentry: { flush: () => Promise } }).Sentry.flush(); + }); + + const metrics = await metricsPromise; + const hit = byName(metrics, 'browser.bfcache.navigation').find(m => attr(m, 'browser.bfcache.outcome') === 'hit'); + + expect(hit).toMatchObject({ name: 'browser.bfcache.navigation', type: 'counter', value: 1 }); + expect(attr(hit!, 'browser.bfcache.navigation_type')).toBe('back-forward-cache'); +}); + +sentryTest('reports outcome:miss on a back/forward navigation that is not restored', async ({ + getLocalTestUrl, + page, + browserName, +}) => { + if (shouldSkipMetricsTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + // Belt-and-suspenders: an unload listener makes the page bfcache-ineligible even if a future + // Playwright stops disabling bfcache by default. + await page.evaluate(() => window.addEventListener('unload', () => {})); + await page.click('#nav'); + await page.waitForLoadState('load'); + + const metricsPromise = waitForMetrics(page, metrics => + byName(metrics, 'browser.bfcache.navigation').some(m => attr(m, 'browser.bfcache.outcome') === 'miss'), + ); + + // Back navigation does a fresh reload (PerformanceNavigationTiming.type === 'back_forward'). + await page.goBack(); + await page.waitForLoadState('load'); + await page.evaluate(() => (window as { Sentry: { flush: () => Promise } }).Sentry.flush()); + + const metrics = await metricsPromise; + const miss = byName(metrics, 'browser.bfcache.navigation').find(m => attr(m, 'browser.bfcache.outcome') === 'miss'); + + expect(miss).toMatchObject({ name: 'browser.bfcache.navigation', type: 'counter', value: 1 }); + expect(attr(miss!, 'browser.bfcache.navigation_type')).toBe('back-forward'); + + // A miss does a real reload, so we also report how expensive that reload was. + const reload = byName(metrics, 'browser.bfcache.reload.duration')[0]; + expect(reload).toMatchObject({ name: 'browser.bfcache.reload.duration', type: 'distribution', unit: 'millisecond' }); + expect(typeof reload?.value).toBe('number'); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a9e7b568ea97..bc5d7ff3b22d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -48,6 +48,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; export { webVitalsIntegration } from './integrations/webVitals'; +export { bfcacheMetricsIntegration } from './integrations/bfcache'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/bfcache.ts b/packages/browser/src/integrations/bfcache.ts new file mode 100644 index 000000000000..195c72d979cf --- /dev/null +++ b/packages/browser/src/integrations/bfcache.ts @@ -0,0 +1,176 @@ +import type { IntegrationFn } from '@sentry/core/browser'; +import { debug, defineIntegration, getCurrentScope, metrics } from '@sentry/core/browser'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; + +const INTEGRATION_NAME = 'BFCacheMetrics'; +const DEFAULT_MAX_REASONS = 5; + +type BFCacheFrame = 'top' | 'child' | 'masked' | 'unknown'; + +interface BFCacheIntegrationOptions { + /** + * Maximum number of not-restored reasons to emit per miss. + * + * Defaults to 5. + */ + maxReasons: number; +} + +interface NotRestoredReason { + reason?: string; +} + +interface NotRestoredReasons { + children?: NotRestoredReasons[] | null; + reasons?: (NotRestoredReason | string)[] | null; +} + +interface NavigationTimingWithNotRestoredReasons extends PerformanceNavigationTiming { + notRestoredReasons?: NotRestoredReasons | null; +} + +interface CollectedReason { + reason: string; + frame: BFCacheFrame; +} + +/** + * Captures bfcache hit/miss counters and Chromium notRestoredReasons when available. + */ +export const bfcacheMetricsIntegration = defineIntegration((options: Partial = {}) => { + const maxReasons = options.maxReasons ?? DEFAULT_MAX_REASONS; + + return { + name: INTEGRATION_NAME, + + setup() { + if (!WINDOW.addEventListener || !WINDOW.performance?.getEntriesByType) { + DEBUG_BUILD && debug.log('[BFCache] Browser APIs unavailable, skipping instrumentation.'); + return; + } + + WINDOW.addEventListener( + 'pageshow', + event => { + const pageTransitionEvent = event as PageTransitionEvent; + + if (pageTransitionEvent.persisted) { + _captureBFCacheNavigation('hit', 'back-forward-cache'); + return; + } + + const navigationEntry = WINDOW.performance.getEntriesByType('navigation')[0] as + | NavigationTimingWithNotRestoredReasons + | undefined; + + if (navigationEntry?.type !== 'back_forward') { + return; + } + + const reasons = _collectNotRestoredReasons(navigationEntry.notRestoredReasons, maxReasons); + + _captureBFCacheNavigation('miss', 'back-forward', reasons.length); + + // Measures how expensive the fallback reload was when a back/forward navigation missed bfcache. + if (typeof navigationEntry.duration === 'number' && navigationEntry.duration > 0) { + const transactionName = _getTransactionName(); + + metrics.distribution('browser.bfcache.reload.duration', navigationEntry.duration, { + unit: 'millisecond', + attributes: { + 'browser.bfcache.navigation_type': 'back-forward', + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); + } + + reasons.forEach(({ reason, frame }) => { + const transactionName = _getTransactionName(); + + metrics.count('browser.bfcache.not_restored', 1, { + attributes: { + 'browser.bfcache.reason': reason, + 'browser.bfcache.frame': frame, + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); + }); + }, + true, + ); + }, + }; +}) satisfies IntegrationFn; + +function _captureBFCacheNavigation( + outcome: 'hit' | 'miss', + navigationType: 'back-forward-cache' | 'back-forward', + reasonCount?: number, +): void { + const transactionName = _getTransactionName(); + + metrics.count('browser.bfcache.navigation', 1, { + attributes: { + 'browser.bfcache.outcome': outcome, + 'browser.bfcache.navigation_type': navigationType, + ...(reasonCount != null ? { 'browser.bfcache.not_restored_reason_count': reasonCount } : {}), + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); +} + +function _getTransactionName(): string | undefined { + return getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; +} + +/** + * Flattens the (possibly nested) bfcache `notRestoredReasons` tree into a capped list of reasons. + * + * Exported for tests only. + */ +export function _collectNotRestoredReasons( + notRestoredReasons: NotRestoredReasons | null | undefined, + maxReasons: number, +): CollectedReason[] { + const reasons: CollectedReason[] = []; + + if (!notRestoredReasons || maxReasons <= 0) { + return reasons; + } + + _collectReasonsFromFrame(notRestoredReasons, 'top', reasons, maxReasons); + + return reasons; +} + +function _collectReasonsFromFrame( + frame: NotRestoredReasons, + frameType: BFCacheFrame, + collectedReasons: CollectedReason[], + maxReasons: number, +): void { + if (collectedReasons.length >= maxReasons) { + return; + } + + frame.reasons?.forEach(reason => { + if (collectedReasons.length >= maxReasons) { + return; + } + + const reasonValue = typeof reason === 'string' ? reason : reason.reason; + if (!reasonValue) { + return; + } + + collectedReasons.push({ + reason: reasonValue, + frame: reasonValue === 'masked' ? 'masked' : frameType, + }); + }); + + frame.children?.forEach(child => { + _collectReasonsFromFrame(child, 'child', collectedReasons, maxReasons); + }); +} diff --git a/packages/browser/test/integrations/bfcache.test.ts b/packages/browser/test/integrations/bfcache.test.ts new file mode 100644 index 000000000000..8e1fb726b7b7 --- /dev/null +++ b/packages/browser/test/integrations/bfcache.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { _collectNotRestoredReasons } from '../../src/integrations/bfcache'; + +describe('bfcacheMetricsIntegration', () => { + describe('_collectNotRestoredReasons', () => { + it('returns an empty list when there are no reasons', () => { + expect(_collectNotRestoredReasons(undefined, 5)).toEqual([]); + expect(_collectNotRestoredReasons(null, 5)).toEqual([]); + expect(_collectNotRestoredReasons({ reasons: [], children: [] }, 5)).toEqual([]); + }); + + it('returns an empty list when maxReasons is zero or negative', () => { + const tree = { reasons: [{ reason: 'unload-listener' }] }; + expect(_collectNotRestoredReasons(tree, 0)).toEqual([]); + expect(_collectNotRestoredReasons(tree, -1)).toEqual([]); + }); + + it('collects top-frame reasons', () => { + const tree = { + reasons: [{ reason: 'unload-listener' }, { reason: 'response-cache-control-no-store' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'response-cache-control-no-store', frame: 'top' }, + ]); + }); + + it('supports both string and object reason shapes', () => { + const tree = { + reasons: ['unload-listener', { reason: 'websocket' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'websocket', frame: 'top' }, + ]); + }); + + it('skips empty/invalid reason entries', () => { + const tree = { + reasons: [{ reason: '' }, {}, { reason: 'unload-listener' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([{ reason: 'unload-listener', frame: 'top' }]); + }); + + it('marks reasons from child frames as "child"', () => { + const tree = { + reasons: [{ reason: 'unload-listener' }], + children: [{ reasons: [{ reason: 'websocket' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'websocket', frame: 'child' }, + ]); + }); + + it('recurses into deeply nested child frames', () => { + const tree = { + reasons: [], + children: [ + { + reasons: [], + children: [{ reasons: [{ reason: 'fetch' }] }], + }, + ], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([{ reason: 'fetch', frame: 'child' }]); + }); + + it('marks the "masked" reason as a masked frame regardless of depth', () => { + const tree = { + reasons: [{ reason: 'masked' }], + children: [{ reasons: [{ reason: 'masked' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'masked', frame: 'masked' }, + { reason: 'masked', frame: 'masked' }, + ]); + }); + + it('caps the number of collected reasons at maxReasons', () => { + const tree = { + reasons: [{ reason: 'a' }, { reason: 'b' }, { reason: 'c' }], + children: [{ reasons: [{ reason: 'd' }, { reason: 'e' }] }], + }; + + const collected = _collectNotRestoredReasons(tree, 2); + expect(collected).toHaveLength(2); + expect(collected).toEqual([ + { reason: 'a', frame: 'top' }, + { reason: 'b', frame: 'top' }, + ]); + }); + + it('stops recursing into children once the cap is reached', () => { + const tree = { + reasons: [{ reason: 'a' }, { reason: 'b' }], + children: [{ reasons: [{ reason: 'c' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 2)).toEqual([ + { reason: 'a', frame: 'top' }, + { reason: 'b', frame: 'top' }, + ]); + }); + }); +}); From 211e69ed64fdadc4cd8685e932d77fdd4d23a3d3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 11:49:26 -0400 Subject: [PATCH 2/8] test(browser): Add real bfcache e2e app, drop simulated suite Adds a dedicated `browser-bfcache` e2e app that exercises `bfcacheMetricsIntegration` against a genuine back/forward cache: real cross-document navigation served by `vite preview`, restored via renderer-initiated `history.back()`, under the full Chrome-for-Testing binary (`channel: 'chromium'`) with the disable flag stripped and `BackForwardCache` enabled. Covers hits, misses with real `notRestoredReasons`, and deliberately bfcache-ineligible pages via `?botch=`: an `unload` listener (stable blocker) and an open WebSocket (blocks only before Chrome 149, so that assertion is version-gated). `Cache-Control: no-store` no longer blocks. Removes the simulated browser-integration bfcache suite, which faked the restore with a synthetic `pageshow` and a manual flush; it is superseded by the real assertions here. The reason-parser unit test stays. --- .../suites/integrations/bfcache/init.js | 9 -- .../suites/integrations/bfcache/page-0.html | 10 -- .../suites/integrations/bfcache/subject.js | 1 - .../suites/integrations/bfcache/template.html | 10 -- .../suites/integrations/bfcache/test.ts | 84 ------------ .../browser-bfcache/.gitignore | 10 ++ .../browser-bfcache/README.md | 24 ++++ .../browser-bfcache/index.html | 13 ++ .../browser-bfcache/package.json | 29 ++++ .../browser-bfcache/page-2.html | 13 ++ .../browser-bfcache/playwright.config.mjs | 30 ++++ .../browser-bfcache/src/main.ts | 43 ++++++ .../browser-bfcache/start-event-proxy.mjs | 6 + .../browser-bfcache/start-ws-server.mjs | 26 ++++ .../browser-bfcache/tests/bfcache.test.ts | 129 ++++++++++++++++++ .../browser-bfcache/tsconfig.json | 15 ++ .../browser-bfcache/vite.config.ts | 20 +++ 17 files changed, 358 insertions(+), 114 deletions(-) delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html delete mode 100644 dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/README.md create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/index.html create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/package.json create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js b/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js deleted file mode 100644 index dcba07a43a70..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/bfcache/init.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -window.Sentry = Sentry; - -Sentry.init({ - debug: true, - dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.bfcacheMetricsIntegration()], -}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html b/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html deleted file mode 100644 index 72ea32047059..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/bfcache/page-0.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - -

BFCache test - second page

- Back to first page - - diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js b/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js deleted file mode 100644 index 1062100d9f8d..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/bfcache/subject.js +++ /dev/null @@ -1 +0,0 @@ -// no-op subject; navigation is driven from the test diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html b/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html deleted file mode 100644 index 114f77b90f41..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/bfcache/template.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - -

BFCache test - first page

- Go to second page - - diff --git a/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts b/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts deleted file mode 100644 index 81ea18092ee2..000000000000 --- a/dev-packages/browser-integration-tests/suites/integrations/bfcache/test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { expect } from '@playwright/test'; -import type { SerializedMetric } from '@sentry/core'; -import { sentryTest } from '../../../utils/fixtures'; -import { shouldSkipMetricsTest, waitForMetrics } from '../../../utils/helpers'; - -function byName(metrics: SerializedMetric[], name: string): SerializedMetric[] { - return metrics.filter(m => m.name === name); -} - -function attr(m: SerializedMetric, key: string): unknown { - return m.attributes?.[key]?.value; -} - -// Note on bfcache under Playwright: -// Playwright launches Chromium with `--disable-back-forward-cache` (and the attached CDP session -// blocks bfcache regardless), so a *real* bfcache restore cannot be reproduced here. We therefore -// drive the hit path by dispatching the same `pageshow` signal the browser would emit on restore, -// which is exactly the contract our integration reacts to. The miss path uses a real back/forward -// navigation, which is always a fresh load in this environment. - -sentryTest('reports outcome:hit when a page is restored from bfcache', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipMetricsTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const metricsPromise = waitForMetrics(page, metrics => - byName(metrics, 'browser.bfcache.navigation').some(m => attr(m, 'browser.bfcache.outcome') === 'hit'), - ); - - // Simulate the browser restoring this page from bfcache. - await page.evaluate(() => { - window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); - // Metrics are buffered and only flushed when the page is hidden, so flush explicitly. - return (window as { Sentry: { flush: () => Promise } }).Sentry.flush(); - }); - - const metrics = await metricsPromise; - const hit = byName(metrics, 'browser.bfcache.navigation').find(m => attr(m, 'browser.bfcache.outcome') === 'hit'); - - expect(hit).toMatchObject({ name: 'browser.bfcache.navigation', type: 'counter', value: 1 }); - expect(attr(hit!, 'browser.bfcache.navigation_type')).toBe('back-forward-cache'); -}); - -sentryTest('reports outcome:miss on a back/forward navigation that is not restored', async ({ - getLocalTestUrl, - page, - browserName, -}) => { - if (shouldSkipMetricsTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - await page.goto(url); - // Belt-and-suspenders: an unload listener makes the page bfcache-ineligible even if a future - // Playwright stops disabling bfcache by default. - await page.evaluate(() => window.addEventListener('unload', () => {})); - await page.click('#nav'); - await page.waitForLoadState('load'); - - const metricsPromise = waitForMetrics(page, metrics => - byName(metrics, 'browser.bfcache.navigation').some(m => attr(m, 'browser.bfcache.outcome') === 'miss'), - ); - - // Back navigation does a fresh reload (PerformanceNavigationTiming.type === 'back_forward'). - await page.goBack(); - await page.waitForLoadState('load'); - await page.evaluate(() => (window as { Sentry: { flush: () => Promise } }).Sentry.flush()); - - const metrics = await metricsPromise; - const miss = byName(metrics, 'browser.bfcache.navigation').find(m => attr(m, 'browser.bfcache.outcome') === 'miss'); - - expect(miss).toMatchObject({ name: 'browser.bfcache.navigation', type: 'counter', value: 1 }); - expect(attr(miss!, 'browser.bfcache.navigation_type')).toBe('back-forward'); - - // A miss does a real reload, so we also report how expensive that reload was. - const reload = byName(metrics, 'browser.bfcache.reload.duration')[0]; - expect(reload).toMatchObject({ name: 'browser.bfcache.reload.duration', type: 'distribution', unit: 'millisecond' }); - expect(typeof reload?.value).toBe('number'); -}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore b/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore new file mode 100644 index 000000000000..6a8319073dbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/dist +.DS_Store + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +*.log +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md new file mode 100644 index 000000000000..6f565ba9c54b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md @@ -0,0 +1,24 @@ +# browser-bfcache + +Exercises `bfcacheMetricsIntegration` against a **real** browser back/forward cache, covering hits, +misses, and the real `notRestoredReasons` the browser reports (Chromium-only, and this app is +Chromium). Deliberately bfcache-ineligible pages are produced via `?botch=` (see `src/main.ts`). + +Why this needs its own app rather than living in `browser-integration-tests`: + +- **Real documents, real server.** bfcache only kicks in on a genuine cross-document history + traversal served over real HTTP. The shared browser suite serves pages via CDP `page.route` + interception on a fake host, which bfcache treats as ineligible. Here two static pages + (`index.html`, `page-2.html`) are built with Vite and served with `vite preview`. It must be the + production build, not `vite dev` (the dev server's HMR websocket is itself a bfcache blocker). +- **A specific Chromium launch recipe** (see `playwright.config.mjs`): the full Chrome-for-Testing + binary (`channel: 'chromium'`, not the default `chromium_headless_shell`, which has no bfcache), + with Playwright's `--disable-back-forward-cache` flag stripped and `BackForwardCache` enabled. +- **Renderer-initiated navigation.** Restores are triggered with `history.back()` from the page; + Playwright's CDP `goBack` bypasses bfcache. + +Blocker cases are version-sensitive (the pinned Chromium comes from the Playwright version): +`unload` is a stable blocker; `Cache-Control: no-store` no longer blocks (CCNS bfcache landed); an +open WebSocket blocks only before Chrome 149, so that assertion is gated on the browser version. + +If other tests later fit these same constraints, this app can be renamed to something broader. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html new file mode 100644 index 000000000000..6539e254cdc6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html @@ -0,0 +1,13 @@ + + + + + + BFCache E2E - Page 1 + + + +

BFCache E2E - Page 1

+ Go to page 2 + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json new file mode 100644 index 000000000000..8db0fc777163 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -0,0 +1,29 @@ +{ + "name": "browser-bfcache-test-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 3030", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "~5.8.3", + "vite": "^7.3.2" + }, + "volta": { + "node": "20.19.2", + "yarn": "1.22.22", + "pnpm": "9.15.9" + } +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html new file mode 100644 index 000000000000..6dd121435ca9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html @@ -0,0 +1,13 @@ + + + + + + BFCache E2E - Page 2 + + + +

BFCache E2E - Page 2

+ Back to page 1 + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs new file mode 100644 index 000000000000..44fa114309ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview`, +}); + +// Real bfcache only restores under the full Chrome-for-Testing binary (`channel: 'chromium'`) with +// Playwright's default `--disable-back-forward-cache` flag stripped and the feature enabled. The +// default headless binary (`chromium_headless_shell`) has no bfcache, so without this the restore +// never happens and the test would be meaningless. +for (const project of config.projects ?? []) { + project.use = { + ...project.use, + channel: 'chromium', + launchOptions: { + ignoreDefaultArgs: ['--disable-back-forward-cache'], + args: ['--enable-features=BackForwardCache'], + }, + }; +} + +// Extra server that holds a WebSocket open, used by the `?botch=websocket` blocker case. +config.webServer.push({ + command: 'node start-ws-server.mjs', + port: 3034, + stdout: 'pipe', + stderr: 'pipe', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts new file mode 100644 index 000000000000..a45c5a51888a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + integrations: [Sentry.bfcacheMetricsIntegration()], + release: 'e2e-test', + environment: 'qa', + tunnel: 'http://localhost:3031', +}); + +(window as unknown as { Sentry: typeof Sentry }).Sentry = Sentry; + +// Test-only marker: lets the test distinguish a genuine bfcache restore (environment working) from a +// fresh reload (environment not restoring), independently of whether our integration emitted a metric. +window.addEventListener( + 'pageshow', + event => { + if ((event as PageTransitionEvent).persisted) { + (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored = true; + } + }, + true, +); + +// Deliberately make this page bfcache-ineligible when the test asks for it via `?botch=...`, so we can +// assert the real miss + notRestoredReasons the browser reports. Each botcher is a documented blocker. +const botch = new URLSearchParams(window.location.search).get('botch'); + +if (botch === 'unload') { + // An `unload` listener is the canonical, version-stable bfcache blocker. + window.addEventListener('unload', () => {}); +} + +if (botch === 'websocket') { + // An open WebSocket blocks bfcache in Chrome < 149; from 149 on it no longer does. + const ws = new WebSocket('ws://localhost:3034'); + const w = window as unknown as { __wsOpen?: boolean; __ws?: WebSocket }; + w.__wsOpen = false; + ws.addEventListener('open', () => { + w.__wsOpen = true; + }); + w.__ws = ws; +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs new file mode 100644 index 000000000000..3104fe87cc85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'browser-bfcache', +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs new file mode 100644 index 000000000000..e9f97faad1c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs @@ -0,0 +1,26 @@ +import crypto from 'crypto'; +import http from 'http'; + +// Minimal WebSocket endpoint used only to hold an open connection open on the page, which makes the +// page bfcache-ineligible in Chrome < 149. It completes the handshake and then does nothing. +const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ws server'); +}); + +server.on('upgrade', (req, socket) => { + const key = req.headers['sec-websocket-key']; + if (!key) { + socket.destroy(); + return; + } + const accept = crypto.createHash('sha1').update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); +}); + +server.listen(3034, () => { + // eslint-disable-next-line no-console + console.log('bfcache ws blocker server listening on 3034'); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts new file mode 100644 index 000000000000..f2557b5e0022 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedMetric } from '@sentry/core'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const PROXY_SERVER_NAME = 'browser-bfcache'; + +function attr(metric: SerializedMetric, key: string): unknown { + return metric.attributes?.[key]?.value; +} + +function isNavigation(metric: SerializedMetric, outcome: 'hit' | 'miss'): boolean { + return metric.name === 'browser.bfcache.navigation' && attr(metric, 'browser.bfcache.outcome') === outcome; +} + +function chromeMajorVersion(browserVersion: string): number { + return parseInt(browserVersion.split('.')[0]!, 10); +} + +test('reports a hit on a genuine back/forward-cache restore', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + // Renderer-initiated history navigation restores the page from bfcache. + // (Playwright's CDP `goBack` bypasses bfcache, so we trigger it from within the page.) + await page.evaluate(() => history.back()); + + // Fail fast with a clear signal if the environment did not actually restore from bfcache. + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + // No manual flush(): this asserts the real capture -> buffer -> send path. + const hit = await hitPromise; + expect(hit.value).toBe(1); + expect(attr(hit, 'browser.bfcache.navigation_type')).toBe('back-forward-cache'); +}); + +test('reports a miss with notRestoredReasons when an unload listener blocks bfcache', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const unloadReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', + ); + const reloadDurationPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => metric.name === 'browser.bfcache.reload.duration', + ); + + await page.goto('/?botch=unload'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + // The unload listener makes the page ineligible, so this back navigation is a fresh reload (a miss). + await page.evaluate(() => history.back()); + await page.waitForFunction( + () => (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type === + 'back_forward', + { timeout: 5000 }, + ); + + const miss = await missPromise; + expect(miss.value).toBe(1); + expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); + expect(attr(miss, 'browser.bfcache.not_restored_reason_count')).toBeGreaterThanOrEqual(1); + + const unloadReason = await unloadReasonPromise; + expect(attr(unloadReason, 'browser.bfcache.frame')).toBe('top'); + + const reloadDuration = await reloadDurationPromise; + expect(reloadDuration.type).toBe('distribution'); + expect(reloadDuration.unit).toBe('millisecond'); + expect(typeof reloadDuration.value).toBe('number'); +}); + +test('reports a miss for an open WebSocket on Chrome < 149 (a hit from 149 on)', async ({ page, browser }) => { + const major = chromeMajorVersion(browser.version()); + const websocketBlocks = major < 149; + + // Set up every waiter before navigating: the navigation and not_restored metrics flush in the same + // envelope, and `waitForMetric` only matches metrics that arrive after it was created. + const outcomePromise = waitForMetric(PROXY_SERVER_NAME, metric => + isNavigation(metric, websocketBlocks ? 'miss' : 'hit'), + ); + const websocketReasonPromise = websocketBlocks + ? waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'websocket', + ) + : null; + + await page.goto('/?botch=websocket'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + // Only proceed once the socket is actually open, otherwise it wouldn't block anything. + await page.waitForFunction(() => (window as unknown as { __wsOpen?: boolean }).__wsOpen === true, { timeout: 5000 }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const outcome = await outcomePromise; + expect(outcome.value).toBe(1); + + if (websocketReasonPromise) { + const websocketReason = await websocketReasonPromise; + expect(attr(websocketReason, 'browser.bfcache.frame')).toBe('top'); + } +}); + +test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + const restored = await page.evaluate( + () => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, + ); + expect(restored).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json new file mode 100644 index 000000000000..9970c2017f2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts new file mode 100644 index 000000000000..3feae813f7ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + // Two separate HTML documents so a back/forward navigation between them is a genuine + // cross-document history traversal that the browser can serve from bfcache. + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + page2: resolve(__dirname, 'page-2.html'), + }, + }, + }, + // Expose the DSN the e2e harness injects to the app code, matching the `process.env.E2E_TEST_DSN` + // convention used by the other browser test apps. + define: { + 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + }, +}); From 09c9be9396f6999af1492753e216da53343175da Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 11:55:03 -0400 Subject: [PATCH 3/8] test(browser): Assert masked-frame notRestoredReason in bfcache e2e The unload miss deterministically also yields a privacy-masked reason. Assert the integration reports it as a `masked` frame, giving real-browser coverage of the masked-frame classification path (previously only unit-tested). --- .../browser-bfcache/tests/bfcache.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index f2557b5e0022..8d7f79697f49 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -47,6 +47,12 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca PROXY_SERVER_NAME, metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', ); + // Chrome reports a privacy-masked reason alongside the real one; the integration must classify it + // as a `masked` frame. This is our only real-browser coverage of the masked-frame path. + const maskedReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'masked', + ); const reloadDurationPromise = waitForMetric( PROXY_SERVER_NAME, metric => metric.name === 'browser.bfcache.reload.duration', @@ -75,6 +81,9 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca const unloadReason = await unloadReasonPromise; expect(attr(unloadReason, 'browser.bfcache.frame')).toBe('top'); + const maskedReason = await maskedReasonPromise; + expect(attr(maskedReason, 'browser.bfcache.frame')).toBe('masked'); + const reloadDuration = await reloadDurationPromise; expect(reloadDuration.type).toBe('distribution'); expect(reloadDuration.unit).toBe('millisecond'); From fcb9ef10cac3f041b0bb92d84f15122baf3fee93 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 12:01:48 -0400 Subject: [PATCH 4/8] test(browser): Cover the IndexedDB versionchange bfcache blocker A plain open IndexedDB connection (and even an in-flight transaction) no longer blocks bfcache in current Chrome, contrary to web.dev's list. The condition that still blocks is a connection holding up a version upgrade, which yields an `idbversionchangeevent` reason. Add a deterministic e2e case for it and document how real Chrome diverges from the article. --- .../browser-bfcache/README.md | 15 ++++++++-- .../browser-bfcache/src/main.ts | 22 ++++++++++++++ .../browser-bfcache/tests/bfcache.test.ts | 30 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md index 6f565ba9c54b..2d9cf7c49977 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md @@ -17,8 +17,17 @@ Why this needs its own app rather than living in `browser-integration-tests`: - **Renderer-initiated navigation.** Restores are triggered with `history.back()` from the page; Playwright's CDP `goBack` bypasses bfcache. -Blocker cases are version-sensitive (the pinned Chromium comes from the Playwright version): -`unload` is a stable blocker; `Cache-Control: no-store` no longer blocks (CCNS bfcache landed); an -open WebSocket blocks only before Chrome 149, so that assertion is gated on the browser version. +Blocker cases are version-sensitive (the pinned Chromium comes from the Playwright version) and real +Chrome is more permissive than web.dev's blocker list suggests. Verified against the pinned Chrome: + +- `unload` listener: blocks (stable across versions). Reason `unload-listener` (plus a `masked` one). +- Open WebSocket: blocks only before Chrome 149, so that assertion is gated on the browser version. +- IndexedDB: a plain open connection and even an in-flight transaction do NOT block; only a + connection holding up a version upgrade does (reason `idbversionchangeevent`). +- `Cache-Control: no-store` and `beforeunload` no longer block. + +Reason extraction/classification (top/child/masked frames, nesting, caps) is exhaustively covered by +the unit test at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real +end-to-end hit/miss + reason path for the deterministic blockers above. If other tests later fit these same constraints, this app can be renamed to something broader. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts index a45c5a51888a..6ee1a6a70f7a 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts @@ -41,3 +41,25 @@ if (botch === 'websocket') { }); w.__ws = ws; } + +if (botch === 'indexeddb') { + // A plain open IndexedDB connection (or even an in-flight transaction) does NOT block bfcache in + // current Chrome. What still blocks is a connection holding up a version upgrade: open v1 without + // closing it on `versionchange`, then request v2 - the upgrade is blocked and the page holds it up. + // A fresh db name per load avoids cross-run persistence. + const dbName = `bf_${Math.random().toString(36).slice(2)}`; + const w = window as unknown as { __idbBlocked?: boolean; __db?: IDBDatabase }; + w.__idbBlocked = false; + + const open1 = indexedDB.open(dbName, 1); + open1.addEventListener('upgradeneeded', event => { + (event.target as IDBOpenDBRequest).result.createObjectStore('s'); + }); + open1.addEventListener('success', event => { + w.__db = (event.target as IDBOpenDBRequest).result; // intentionally no `versionchange` handler + const open2 = indexedDB.open(dbName, 2); + open2.addEventListener('blocked', () => { + w.__idbBlocked = true; + }); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 8d7f79697f49..948040b608d5 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -127,6 +127,36 @@ test('reports a miss for an open WebSocket on Chrome < 149 (a hit from 149 on)', } }); +test('reports a miss with an idbversionchangeevent reason when a connection blocks an upgrade', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const reasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'idbversionchangeevent', + ); + + await page.goto('/?botch=indexeddb'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + // Only proceed once the version upgrade is actually blocked by the open connection. + await page.waitForFunction(() => (window as unknown as { __idbBlocked?: boolean }).__idbBlocked === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); + + const reason = await reasonPromise; + expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); +}); + test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); From fc820d32fc08217388ab85e3f18acaff3da10514 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 12:21:07 -0400 Subject: [PATCH 5/8] test(browser): Cover the no-store + cookie-change bfcache miss A no-store (CCNS) page is only evicted from bfcache once a cookie changes; neither the header nor a cookie change blocks on its own. Add a `?botch=nostore` case that serves the document with `Cache-Control: no-store` (via a preview middleware, since a static server can't set per-request headers) and mutates a cookie, asserting the `response-cache-control-no-store` reason. Also trim the version-specific blocker details from the README so they don't drift; the botch cases and their assertions are the source of truth. --- CHANGELOG.md | 1 - .../browser-bfcache/README.md | 17 ++++----- .../browser-bfcache/src/main.ts | 7 ++++ .../browser-bfcache/tests/bfcache.test.ts | 36 +++++++++++++++++-- .../browser-bfcache/vite.config.ts | 27 ++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0e95bfb904..79ca980239cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ ``` It emits: - - `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`) and navigation type. - `browser.bfcache.not_restored` — a counter of the (Chromium-only) `notRestoredReasons` for a miss. - `browser.bfcache.reload.duration` — a distribution of how expensive the fallback reload was on a miss. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md index 2d9cf7c49977..86318a9986d8 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md @@ -17,17 +17,12 @@ Why this needs its own app rather than living in `browser-integration-tests`: - **Renderer-initiated navigation.** Restores are triggered with `history.back()` from the page; Playwright's CDP `goBack` bypasses bfcache. -Blocker cases are version-sensitive (the pinned Chromium comes from the Playwright version) and real -Chrome is more permissive than web.dev's blocker list suggests. Verified against the pinned Chrome: +Which conditions actually block bfcache (and the exact reason strings) is version-specific and more +permissive than web.dev's list suggests, so the individual `?botch=` cases and their assertions are +the source of truth, not prose here. Some are gated on the browser version where behavior changed. -- `unload` listener: blocks (stable across versions). Reason `unload-listener` (plus a `masked` one). -- Open WebSocket: blocks only before Chrome 149, so that assertion is gated on the browser version. -- IndexedDB: a plain open connection and even an in-flight transaction do NOT block; only a - connection holding up a version upgrade does (reason `idbversionchangeevent`). -- `Cache-Control: no-store` and `beforeunload` no longer block. - -Reason extraction/classification (top/child/masked frames, nesting, caps) is exhaustively covered by -the unit test at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real -end-to-end hit/miss + reason path for the deterministic blockers above. +Reason extraction/classification (top/child/masked frames, nesting, caps) is covered by the unit test +at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real end-to-end +hit/miss + reason path. If other tests later fit these same constraints, this app can be renamed to something broader. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts index 6ee1a6a70f7a..cc5c2aff161f 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts @@ -42,6 +42,13 @@ if (botch === 'websocket') { w.__ws = ws; } +if (botch === 'nostore') { + // The document is served with `Cache-Control: no-store` (see vite.config.ts). A CCNS page is + // cached but evicted once a cookie changes, so mutate a cookie to force the miss. + document.cookie = 'bf=1; Path=/'; + (window as unknown as { __nostoreReady?: boolean }).__nostoreReady = true; +} + if (botch === 'indexeddb') { // A plain open IndexedDB connection (or even an in-flight transaction) does NOT block bfcache in // current Chrome. What still blocks is a connection holding up a version upgrade: open v1 without diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 948040b608d5..33871c37fd02 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -45,7 +45,8 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); const unloadReasonPromise = waitForMetric( PROXY_SERVER_NAME, - metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', + metric => + metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', ); // Chrome reports a privacy-masked reason alongside the real one; the integration must classify it // as a `masked` frame. This is our only real-browser coverage of the masked-frame path. @@ -68,7 +69,8 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca // The unload listener makes the page ineligible, so this back navigation is a fresh reload (a miss). await page.evaluate(() => history.back()); await page.waitForFunction( - () => (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type === + () => + (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type === 'back_forward', { timeout: 5000 }, ); @@ -157,6 +159,36 @@ test('reports a miss with an idbversionchangeevent reason when a connection bloc expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); }); +test('reports a miss with a response-cache-control-no-store reason when a CCNS page cookie changes', async ({ + page, +}) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const reasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'response-cache-control-no-store', + ); + + await page.goto('/?botch=nostore'); + await page.waitForFunction(() => (window as unknown as { __nostoreReady?: boolean }).__nostoreReady === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); + + const reason = await reasonPromise; + expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); +}); + test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts index 3feae813f7ed..de0247345d15 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts @@ -17,4 +17,31 @@ export default defineConfig({ define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), }, + plugins: [ + { + // The `?botch=nostore` case needs the document served with `Cache-Control: no-store`; a later + // cookie mutation then makes the browser evict the CCNS page from bfcache. Neither the header + // nor the cookie change blocks on its own - only the combination does. A static server can't + // set per-request headers, so add it here for that URL only. + name: 'bfcache-nostore-headers', + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url?.includes('botch=nostore')) { + res.setHeader('Cache-Control', 'no-store'); + // vite's static handler runs after this and would otherwise reset Cache-Control to + // `no-cache`, so lock the header for this request only. + const originalSetHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => { + if (String(name).toLowerCase() === 'cache-control') { + return res; + } + + return originalSetHeader(name, value); + }; + } + next(); + }); + }, + }, + ], }); From f28dd4aa5a6c6744607186df5f563c40c2aca5a1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 14:37:58 -0400 Subject: [PATCH 6/8] refactor(browser): Drop redundant bfcache navigation_type attribute `browser.bfcache.navigation_type` was 1:1 with `browser.bfcache.outcome` (hit <-> back-forward-cache, miss <-> back-forward), so it added a second attribute encoding the same bit. Keep only `outcome`; navigation-type vocabulary belongs on web-vital samples (future soft-navigation work), not on bfcache counters. Also drops an unnecessary PageTransitionEvent cast. --- CHANGELOG.md | 2 +- .../browser-bfcache/tests/bfcache.test.ts | 6 ------ packages/browser/src/integrations/bfcache.ts | 16 ++++------------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79ca980239cb..1e1db309ab78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ ``` It emits: - - `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`) and navigation type. + - `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`). - `browser.bfcache.not_restored` — a counter of the (Chromium-only) `notRestoredReasons` for a miss. - `browser.bfcache.reload.duration` — a distribution of how expensive the fallback reload was on a miss. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 33871c37fd02..4f65ede3e993 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -38,7 +38,6 @@ test('reports a hit on a genuine back/forward-cache restore', async ({ page }) = // No manual flush(): this asserts the real capture -> buffer -> send path. const hit = await hitPromise; expect(hit.value).toBe(1); - expect(attr(hit, 'browser.bfcache.navigation_type')).toBe('back-forward-cache'); }); test('reports a miss with notRestoredReasons when an unload listener blocks bfcache', async ({ page }) => { @@ -77,7 +76,6 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca const miss = await missPromise; expect(miss.value).toBe(1); - expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); expect(attr(miss, 'browser.bfcache.not_restored_reason_count')).toBeGreaterThanOrEqual(1); const unloadReason = await unloadReasonPromise; @@ -153,8 +151,6 @@ test('reports a miss with an idbversionchangeevent reason when a connection bloc const miss = await missPromise; expect(miss.value).toBe(1); - expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); - const reason = await reasonPromise; expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); }); @@ -183,8 +179,6 @@ test('reports a miss with a response-cache-control-no-store reason when a CCNS p const miss = await missPromise; expect(miss.value).toBe(1); - expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward'); - const reason = await reasonPromise; expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); }); diff --git a/packages/browser/src/integrations/bfcache.ts b/packages/browser/src/integrations/bfcache.ts index 195c72d979cf..6a30981389b7 100644 --- a/packages/browser/src/integrations/bfcache.ts +++ b/packages/browser/src/integrations/bfcache.ts @@ -53,10 +53,8 @@ export const bfcacheMetricsIntegration = defineIntegration((options: Partial { - const pageTransitionEvent = event as PageTransitionEvent; - - if (pageTransitionEvent.persisted) { - _captureBFCacheNavigation('hit', 'back-forward-cache'); + if (event.persisted) { + _captureBFCacheNavigation('hit'); return; } @@ -70,7 +68,7 @@ export const bfcacheMetricsIntegration = defineIntegration((options: Partial 0) { @@ -79,7 +77,6 @@ export const bfcacheMetricsIntegration = defineIntegration((options: Partial Date: Mon, 20 Jul 2026 15:02:13 -0400 Subject: [PATCH 7/8] test(browser): Cover bfcache iframe eligibility and child-frame reasons Adds two real-browser e2e cases: an embedded bfcache-eligible iframe keeps the top page restorable (hit), and an iframe with an unload listener makes the top page ineligible with the reason reported from the child frame. This gives the `frame: 'child'` classification real coverage (previously unit-only). --- .../browser-bfcache/iframe.html | 18 +++++++ .../browser-bfcache/src/main.ts | 13 +++++ .../browser-bfcache/tests/bfcache.test.ts | 52 +++++++++++++++++++ .../browser-bfcache/vite.config.ts | 1 + 4 files changed, 84 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html new file mode 100644 index 000000000000..ac5f4912ee94 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html @@ -0,0 +1,18 @@ + + + + + BFCache E2E - iframe + + + + iframe + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts index cc5c2aff161f..e53f75c4a45a 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts @@ -70,3 +70,16 @@ if (botch === 'indexeddb') { }); }); } + +if (botch === 'iframe-clean' || botch === 'iframe-unload') { + // Embed a same-origin child frame. A clean child keeps the top page eligible (hit); a child with an + // unload listener makes the whole top page ineligible, and the reason comes from the child frame. + const iframe = document.createElement('iframe'); + iframe.src = botch === 'iframe-unload' ? '/iframe.html?blocker=unload' : '/iframe.html'; + const w = window as unknown as { __iframeLoaded?: boolean }; + w.__iframeLoaded = false; + iframe.addEventListener('load', () => { + w.__iframeLoaded = true; + }); + document.body.appendChild(iframe); +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 4f65ede3e993..7c3f3f333c43 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -183,6 +183,58 @@ test('reports a miss with a response-cache-control-no-store reason when a CCNS p expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); }); +test('restores a page whose embedded iframe is bfcache-eligible', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/?botch=iframe-clean'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + await page.waitForFunction(() => (window as unknown as { __iframeLoaded?: boolean }).__iframeLoaded === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + const hit = await hitPromise; + expect(hit.value).toBe(1); +}); + +test('reports a child-frame reason when an ineligible iframe blocks the top page', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + // The blocker lives in the child frame, so the reason must be classified as a `child` frame. + const childReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'unload-listener' && + attr(metric, 'browser.bfcache.frame') === 'child', + ); + + await page.goto('/?botch=iframe-unload'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + await page.waitForFunction(() => (window as unknown as { __iframeLoaded?: boolean }).__iframeLoaded === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + + const childReason = await childReasonPromise; + expect(attr(childReason, 'browser.bfcache.frame')).toBe('child'); +}); + test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts index de0247345d15..63ad5a42871b 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), page2: resolve(__dirname, 'page-2.html'), + iframe: resolve(__dirname, 'iframe.html'), }, }, }, From 4cb315eb077e800e66f24aa59922714875ffcf19 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 20 Jul 2026 15:26:05 -0400 Subject: [PATCH 8/8] refactor(browser): Make bfcache frame purely positional `frame` no longer special-cases the `masked` reason value; it now reflects only the frame's position in the notRestoredReasons tree (`top`/`child`). The `masked` value already lives on the `reason` attribute, so tagging the frame as `masked` too was redundant. The integration no longer inspects any reason string - reasons pass through verbatim. --- .../test-applications/browser-bfcache/tests/bfcache.test.ts | 6 +++--- packages/browser/src/integrations/bfcache.ts | 4 ++-- packages/browser/test/integrations/bfcache.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 7c3f3f333c43..a057d02813aa 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -47,8 +47,8 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', ); - // Chrome reports a privacy-masked reason alongside the real one; the integration must classify it - // as a `masked` frame. This is our only real-browser coverage of the masked-frame path. + // Chrome reports a privacy-masked reason alongside the real one. It's a top-frame reason here, so + // the integration must frame it positionally (`top`) and pass the value through untouched. const maskedReasonPromise = waitForMetric( PROXY_SERVER_NAME, metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'masked', @@ -82,7 +82,7 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca expect(attr(unloadReason, 'browser.bfcache.frame')).toBe('top'); const maskedReason = await maskedReasonPromise; - expect(attr(maskedReason, 'browser.bfcache.frame')).toBe('masked'); + expect(attr(maskedReason, 'browser.bfcache.frame')).toBe('top'); const reloadDuration = await reloadDurationPromise; expect(reloadDuration.type).toBe('distribution'); diff --git a/packages/browser/src/integrations/bfcache.ts b/packages/browser/src/integrations/bfcache.ts index 6a30981389b7..4894f9cce249 100644 --- a/packages/browser/src/integrations/bfcache.ts +++ b/packages/browser/src/integrations/bfcache.ts @@ -6,7 +6,7 @@ import { WINDOW } from '../helpers'; const INTEGRATION_NAME = 'BFCacheMetrics'; const DEFAULT_MAX_REASONS = 5; -type BFCacheFrame = 'top' | 'child' | 'masked' | 'unknown'; +type BFCacheFrame = 'top' | 'child'; interface BFCacheIntegrationOptions { /** @@ -158,7 +158,7 @@ function _collectReasonsFromFrame( collectedReasons.push({ reason: reasonValue, - frame: reasonValue === 'masked' ? 'masked' : frameType, + frame: frameType, }); }); diff --git a/packages/browser/test/integrations/bfcache.test.ts b/packages/browser/test/integrations/bfcache.test.ts index 8e1fb726b7b7..2fe3097b1ba6 100644 --- a/packages/browser/test/integrations/bfcache.test.ts +++ b/packages/browser/test/integrations/bfcache.test.ts @@ -71,15 +71,15 @@ describe('bfcacheMetricsIntegration', () => { expect(_collectNotRestoredReasons(tree, 5)).toEqual([{ reason: 'fetch', frame: 'child' }]); }); - it('marks the "masked" reason as a masked frame regardless of depth', () => { + it('frames every reason by its position, without special-casing the reason value', () => { const tree = { reasons: [{ reason: 'masked' }], children: [{ reasons: [{ reason: 'masked' }] }], }; expect(_collectNotRestoredReasons(tree, 5)).toEqual([ - { reason: 'masked', frame: 'masked' }, - { reason: 'masked', frame: 'masked' }, + { reason: 'masked', frame: 'top' }, + { reason: 'masked', frame: 'child' }, ]); });