mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
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.
146 lines
5.2 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|