Skip to content

Commit fc820d3

Browse files
committed
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.
1 parent fcb9ef1 commit fc820d3

5 files changed

Lines changed: 74 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
```
1717

1818
It emits:
19-
2019
- `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`) and navigation type.
2120
- `browser.bfcache.not_restored` — a counter of the (Chromium-only) `notRestoredReasons` for a miss.
2221
- `browser.bfcache.reload.duration` — a distribution of how expensive the fallback reload was on a miss.

dev-packages/e2e-tests/test-applications/browser-bfcache/README.md

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,12 @@ Why this needs its own app rather than living in `browser-integration-tests`:
1717
- **Renderer-initiated navigation.** Restores are triggered with `history.back()` from the page;
1818
Playwright's CDP `goBack` bypasses bfcache.
1919

20-
Blocker cases are version-sensitive (the pinned Chromium comes from the Playwright version) and real
21-
Chrome is more permissive than web.dev's blocker list suggests. Verified against the pinned Chrome:
20+
Which conditions actually block bfcache (and the exact reason strings) is version-specific and more
21+
permissive than web.dev's list suggests, so the individual `?botch=` cases and their assertions are
22+
the source of truth, not prose here. Some are gated on the browser version where behavior changed.
2223

23-
- `unload` listener: blocks (stable across versions). Reason `unload-listener` (plus a `masked` one).
24-
- Open WebSocket: blocks only before Chrome 149, so that assertion is gated on the browser version.
25-
- IndexedDB: a plain open connection and even an in-flight transaction do NOT block; only a
26-
connection holding up a version upgrade does (reason `idbversionchangeevent`).
27-
- `Cache-Control: no-store` and `beforeunload` no longer block.
28-
29-
Reason extraction/classification (top/child/masked frames, nesting, caps) is exhaustively covered by
30-
the unit test at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real
31-
end-to-end hit/miss + reason path for the deterministic blockers above.
24+
Reason extraction/classification (top/child/masked frames, nesting, caps) is covered by the unit test
25+
at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real end-to-end
26+
hit/miss + reason path.
3227

3328
If other tests later fit these same constraints, this app can be renamed to something broader.

dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ if (botch === 'websocket') {
4242
w.__ws = ws;
4343
}
4444

45+
if (botch === 'nostore') {
46+
// The document is served with `Cache-Control: no-store` (see vite.config.ts). A CCNS page is
47+
// cached but evicted once a cookie changes, so mutate a cookie to force the miss.
48+
document.cookie = 'bf=1; Path=/';
49+
(window as unknown as { __nostoreReady?: boolean }).__nostoreReady = true;
50+
}
51+
4552
if (botch === 'indexeddb') {
4653
// A plain open IndexedDB connection (or even an in-flight transaction) does NOT block bfcache in
4754
// current Chrome. What still blocks is a connection holding up a version upgrade: open v1 without

dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca
4545
const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss'));
4646
const unloadReasonPromise = waitForMetric(
4747
PROXY_SERVER_NAME,
48-
metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener',
48+
metric =>
49+
metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener',
4950
);
5051
// Chrome reports a privacy-masked reason alongside the real one; the integration must classify it
5152
// 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
6869
// The unload listener makes the page ineligible, so this back navigation is a fresh reload (a miss).
6970
await page.evaluate(() => history.back());
7071
await page.waitForFunction(
71-
() => (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type ===
72+
() =>
73+
(performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type ===
7274
'back_forward',
7375
{ timeout: 5000 },
7476
);
@@ -157,6 +159,36 @@ test('reports a miss with an idbversionchangeevent reason when a connection bloc
157159
expect(attr(reason, 'browser.bfcache.frame')).toBe('top');
158160
});
159161

162+
test('reports a miss with a response-cache-control-no-store reason when a CCNS page cookie changes', async ({
163+
page,
164+
}) => {
165+
const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss'));
166+
const reasonPromise = waitForMetric(
167+
PROXY_SERVER_NAME,
168+
metric =>
169+
metric.name === 'browser.bfcache.not_restored' &&
170+
attr(metric, 'browser.bfcache.reason') === 'response-cache-control-no-store',
171+
);
172+
173+
await page.goto('/?botch=nostore');
174+
await page.waitForFunction(() => (window as unknown as { __nostoreReady?: boolean }).__nostoreReady === true, {
175+
timeout: 5000,
176+
});
177+
178+
await page.click('#to-page-2');
179+
await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2');
180+
await page.waitForTimeout(500);
181+
182+
await page.evaluate(() => history.back());
183+
184+
const miss = await missPromise;
185+
expect(miss.value).toBe(1);
186+
expect(attr(miss, 'browser.bfcache.navigation_type')).toBe('back-forward');
187+
188+
const reason = await reasonPromise;
189+
expect(attr(reason, 'browser.bfcache.frame')).toBe('top');
190+
});
191+
160192
test('does not treat an ordinary forward navigation as a restore', async ({ page }) => {
161193
await page.goto('/');
162194
await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1');

dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,31 @@ export default defineConfig({
1717
define: {
1818
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN),
1919
},
20+
plugins: [
21+
{
22+
// The `?botch=nostore` case needs the document served with `Cache-Control: no-store`; a later
23+
// cookie mutation then makes the browser evict the CCNS page from bfcache. Neither the header
24+
// nor the cookie change blocks on its own - only the combination does. A static server can't
25+
// set per-request headers, so add it here for that URL only.
26+
name: 'bfcache-nostore-headers',
27+
configurePreviewServer(server) {
28+
server.middlewares.use((req, res, next) => {
29+
if (req.url?.includes('botch=nostore')) {
30+
res.setHeader('Cache-Control', 'no-store');
31+
// vite's static handler runs after this and would otherwise reset Cache-Control to
32+
// `no-cache`, so lock the header for this request only.
33+
const originalSetHeader = res.setHeader.bind(res);
34+
res.setHeader = (name, value) => {
35+
if (String(name).toLowerCase() === 'cache-control') {
36+
return res;
37+
}
38+
39+
return originalSetHeader(name, value);
40+
};
41+
}
42+
next();
43+
});
44+
},
45+
},
46+
],
2047
});

0 commit comments

Comments
 (0)