Files
Hendrik LiebauandGitHub 5b0aa04b10 Persist 'use cache: private' entries in dev (#94694)
Private caches were never stored in a cache handler, so every reload in
`next dev` re-ran them from scratch and they registered as a cache miss
on each load. This change persists `'use cache: private'` entries in
development in a dedicated built-in in-memory handler so that warm
reloads are fast. The handler is gated on
`process.env.__NEXT_DEV_SERVER` and is kept out of the kind-keyed
handlers map so it can never be replaced by a user-configured `default`
handler: private cache entries can hold data specific to the incoming
request (for example, derived from its cookies or headers) and must
never reach a remote or otherwise persistent handler. Production keeps
private caches non-persisted as before.

The coarse cache-handler key for a dev private cache is scoped by the
request's cookies and headers so entries for requests with different
request data don't collide. It excludes Next-internal cookies that
aren't application data (the HMR refresh hash, already part of the cache
key, and the instant-navigation cookie, which toggles while a navigation
lock is held) and the transport and content-negotiation headers that
vary between otherwise-equivalent requests (a browser reload adds
`cache-control`, and `accept` and `sec-fetch-*` differ between an HTML
navigation and an RSC request). Keying by only the cookies and headers a
cache actually reads is left as a follow-up; read root params are
already tracked that way, the same as for public caches. The cache life
is forced to `revalidate: 0` with a 5-minute `expire`, so each read
serves the stale entry immediately and warms a fresh one in the
background through the existing stale-while-revalidate path.

Cross-request deduplication now applies to private caches in development
too, so concurrent requests with identical request data share a single
fill; it remains skipped in production where request-specific data must
not be shared across requests. To make this deterministic,
`saveToCacheHandler` resolves the metadata a cross-request joiner awaits
only after the entry has been written to the handler, so the joiner
finds it when re-reading its recomputed key. This closes a pre-existing
race in that path, present for public caches too but never surfaced by
their cross-request test, where the joiner's metadata could resolve
before the handler write had landed.

The defensive invariant that rejected reading a private entry from a
handler is removed: it only existed to narrow `cacheContext.kind` for a
code path that no longer needs it, and dev now legitimately reads
persisted private entries while production never registers a private
handler to read from.
2026-06-11 22:30:08 +02:00

146 lines
5.2 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
const pprEnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'
describe('use-cache-private', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
it('excludes private caches from prerenders', async () => {
const browser = await next.browser('/')
expect(await browser.elementById('page-sentinel').text()).toBe(
isNextDev || !pprEnabled ? 'runtime' : 'buildtime'
)
expect(await browser.elementById('private-sentinel').text()).toBe('runtime')
})
it('allows reading cookies in private caches', async () => {
const browser = await next.browser('/cookies')
expect(await browser.elementById('test-cookie').text()).toBe('<empty>')
await browser.addCookie({ name: 'test-cookie', value: 'foo' })
await browser.refresh()
expect(await browser.elementById('test-cookie').text()).toBe('foo')
})
it('allows reading search params in private caches', async () => {
const browser = await next.browser('/search-params?q=foo')
expect(await browser.elementById('search-param').text()).toBe('foo')
await browser.loadPage(new URL('/search-params?q=bar', next.url).href)
expect(await browser.elementById('search-param').text()).toBe('bar')
})
it('serves a stale entry on reload in dev and warms a fresh one in the background', async () => {
const browser = await next.browser('/stale-while-revalidate')
const initialValue = await browser.waitForElementByCss('#value').text()
await browser.refresh()
const reloadedValue = await browser.waitForElementByCss('#value').text()
if (isNextDev) {
// In dev the private entry is persisted and forced to `revalidate: 0`, so
// it is always stale and served immediately on reload while a fresh entry
// warms in the background.
expect(reloadedValue).toBe(initialValue)
// A subsequent reload reflects the freshly warmed value.
await retry(async () => {
await browser.refresh()
const warmedValue = await browser.waitForElementByCss('#value').text()
expect(warmedValue).not.toBe(initialValue)
})
} else {
// In production private entries are not persisted, so every load re-runs
// the cache and shows a fresh value.
expect(reloadedValue).not.toBe(initialValue)
}
})
it('keys persisted entries by cookies in dev', async () => {
const browser = await next.browser('/stale-while-revalidate')
await browser.addCookie({ name: 'use-cache-private-test', value: 'a' })
await browser.refresh()
const valueA = await browser.waitForElementByCss('#value').text()
await browser.addCookie({ name: 'use-cache-private-test', value: 'b' })
await browser.refresh()
const valueB = await browser.waitForElementByCss('#value').text()
// A different cookie produces a different private entry.
expect(valueB).not.toBe(valueA)
await browser.addCookie({ name: 'use-cache-private-test', value: 'a' })
await browser.refresh()
const valueA2 = await browser.waitForElementByCss('#value').text()
if (isNextDev) {
// Switching back to the original cookie hits its own persisted entry,
// which proves the entry is keyed by the cookie (and not shared with the
// other cookie's entry).
expect(valueA2).toBe(valueA)
} else {
// Private entries are not persisted in production.
expect(valueA2).not.toBe(valueA)
}
})
it('keys persisted entries by headers in dev', async () => {
const renderValue = async (headerValue: string) => {
const $ = await next.render$('/stale-while-revalidate', undefined, {
headers: { 'x-use-cache-private-test': headerValue },
})
return $('#value').text()
}
const valueA = await renderValue('a')
const valueB = await renderValue('b')
// A different header value produces a different private entry.
expect(valueB).not.toBe(valueA)
const valueA2 = await renderValue('a')
if (isNextDev) {
// Re-sending the original header value hits its own persisted entry, which
// proves the entry is keyed by request headers (and not shared with the
// other header value's entry).
expect(valueA2).toBe(valueA)
} else {
// Private entries are not persisted in production.
expect(valueA2).not.toBe(valueA)
}
})
it('revalidates a persisted entry by tag in dev', async () => {
if (!isNextDev) {
// Private caches are only persisted in development, so tag revalidation
// of a persisted private entry is a dev-only concern.
return
}
const browser = await next.browser('/update-tag')
expect(await browser.waitForElementByCss('#value').text()).toBe('initial')
// The server action sets the value to 'updated' and revalidates the tag,
// then triggers a refresh. That refresh's private read must already reflect
// the new value: `updateTag` invalidated the persisted entry, so it is
// regenerated rather than served stale (which would still show 'initial' on
// this first read, only converging on a later one).
await browser.elementById('update').click()
await retry(async () => {
expect(await browser.elementByCss('#value').text()).toBe('updated')
})
})
})