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

357 lines
15 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { join } from 'path'
import { assertNoConsoleErrors, retry, waitForNoRedbox } from 'next-test-utils'
describe('app-root-param-getters - cache - at runtime', () => {
const { next, isNextDev, isNextDeploy } = nextTestSetup({
files: join(__dirname, 'fixtures', 'use-cache-runtime'),
})
if (isNextDev) {
it('should allow using root params within a "use cache" - dev', async () => {
const browser = await next.browser('/en/us/use-cache')
await waitForNoRedbox(browser)
expect(await browser.elementById('param').text()).toBe('en us')
})
it('should error when using root params within `unstable_cache` - dev', async () => {
const browser = await next.browser('/en/us/unstable_cache')
await expect(browser).toDisplayRedbox(`
{
"code": "E1141",
"description": "Route /[lang]/[countryCode]/unstable_cache used \`import('next/root-params').lang()\` inside \`unstable_cache\`. This is not supported. Use \`"use cache"\` instead.",
"environmentLabel": "Server",
"label": "Runtime Error",
"source": "app/[lang]/[countryCode]/unstable_cache/page.tsx (33:28) @ uncachedGetParams
> 33 | return { lang: await lang(), countryCode: await countryCode() }
| ^",
"stack": [
"uncachedGetParams app/[lang]/[countryCode]/unstable_cache/page.tsx (33:28)",
"Runtime app/[lang]/[countryCode]/unstable_cache/page.tsx (17:22)",
],
}
`)
})
it('should error when using root params in "use cache" nested inside unstable_cache - dev', async () => {
const browser = await next.browser('/en/us/nested-in-unstable_cache')
await expect(browser).toDisplayRedbox(`
{
"code": "E1140",
"description": "Route /[lang]/[countryCode]/nested-in-unstable_cache used \`import('next/root-params').lang()\` inside \`"use cache"\` nested within \`unstable_cache\`. Root params are not available in this context.",
"environmentLabel": "Cache",
"label": "Runtime Error",
"source": "app/[lang]/[countryCode]/nested-in-unstable_cache/page.tsx (29:28) @ getCachedParams
> 29 | return { lang: await lang(), countryCode: await countryCode() }
| ^",
"stack": [
"getCachedParams app/[lang]/[countryCode]/nested-in-unstable_cache/page.tsx (29:28)",
],
}
`)
})
} else {
it('should allow using root params within a "use cache" - start', async () => {
const browser = await next.browser('/en/us/use-cache')
expect(await browser.elementById('param').text()).toBe('en us')
})
it('should create separate cache entries for different root params', async () => {
const browser = await next.browser('/en/us/use-cache')
expect(await browser.elementById('param').text()).toBe('en us')
const enRandom = await browser.elementById('random').text()
await browser.loadPage(next.url + '/fr/ca/use-cache')
expect(await browser.elementById('param').text()).toBe('fr ca')
const frRandom = await browser.elementById('random').text()
// Different root params must produce different cache entries.
expect(enRandom).not.toBe(frRandom)
// Each entry must be individually cached (same random on revisit).
await browser.loadPage(next.url + '/en/us/use-cache')
expect(await browser.elementById('random').text()).toBe(enRandom)
await browser.loadPage(next.url + '/fr/ca/use-cache')
expect(await browser.elementById('random').text()).toBe(frRandom)
})
it('should resume with the same cached data that was prerendered', async () => {
const browser = await next.browser('/en/us/use-cache-resume', {
pushErrorAsConsoleLog: true,
})
expect(await browser.elementById('random').text()).toBeTruthy()
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('dynamic')
})
// If the Resume Data Cache lookup missed during the resume (e.g. due
// to a key mismatch caused by root params), the "use cache" function
// would re-execute and produce a different random value than the one
// in the prerendered shell, causing a hydration error.
await assertNoConsoleErrors(browser)
})
it('should handle conditional root param reads based on arguments', async () => {
const browser = await next.browser('/en/us/maybe-reads-root-param')
expect(await browser.elementById('with-lang-value').text()).toBe('en')
expect(await browser.elementById('without-lang-value').text()).toBe(
'null'
)
const enWithRandom = await browser.elementById('with-lang-random').text()
const enWithoutRandom = await browser
.elementById('without-lang-random')
.text()
await browser.loadPage(next.url + '/fr/ca/maybe-reads-root-param')
expect(await browser.elementById('with-lang-value').text()).toBe('fr')
expect(await browser.elementById('without-lang-value').text()).toBe(
'null'
)
const frWithRandom = await browser.elementById('with-lang-random').text()
const frWithoutRandom = await browser
.elementById('without-lang-random')
.text()
// Different root params produce different cache entries.
expect(frWithRandom).not.toBe(enWithRandom)
// The without-lang call doesn't read lang, but after
// knownRootParamsByFunctionId grows, the key includes lang too, so
// different root params still produce different entries.
expect(frWithoutRandom).not.toBe(enWithoutRandom)
// Revisit to confirm cache hits.
await browser.loadPage(next.url + '/en/us/maybe-reads-root-param')
expect(await browser.elementById('with-lang-random').text()).toBe(
enWithRandom
)
expect(await browser.elementById('without-lang-random').text()).toBe(
enWithoutRandom
)
await browser.loadPage(next.url + '/fr/ca/maybe-reads-root-param')
expect(await browser.elementById('with-lang-random').text()).toBe(
frWithRandom
)
expect(await browser.elementById('without-lang-random').text()).toBe(
frWithoutRandom
)
})
it('should handle root param reads introduced after revalidation', async () => {
const browser = await next.browser('/en/us/conditional-on-another-cache')
// Reset the flag in case a previous test attempt left it enabled.
if ((await browser.elementById('lang-value').text()) !== 'null') {
await browser.elementById('disable-flag').click()
await retry(async () => {
expect(await browser.elementById('lang-value').text()).toBe('null')
})
}
// Before the flag is enabled, lang is not read on any route.
await browser.loadPage(next.url + '/fr/ca/conditional-on-another-cache')
expect(await browser.elementById('lang-value').text()).toBe('null')
// Enable the flag and revalidate (staying on the fr/ca page).
await browser.elementById('enable-flag').click()
await retry(async () => {
expect(await browser.elementById('lang-value').text()).toBe('fr')
})
// Wait for the cache to settle by verifying the random value is
// stable across two consecutive loads.
let frRandom: string = ''
await retry(async () => {
await browser.loadPage(next.url + '/fr/ca/conditional-on-another-cache')
frRandom = await browser.elementById('random').text()
await browser.loadPage(next.url + '/fr/ca/conditional-on-another-cache')
expect(await browser.elementById('random').text()).toBe(frRandom)
})
// Different root params must now produce different entries because
// lang is being read.
await browser.loadPage(next.url + '/en/us/conditional-on-another-cache')
expect(await browser.elementById('lang-value').text()).toBe('en')
const enRandom = await browser.elementById('random').text()
expect(enRandom).not.toBe(frRandom)
// Revisit fr/ca to confirm cache hit.
await browser.loadPage(next.url + '/fr/ca/conditional-on-another-cache')
expect(await browser.elementById('random').text()).toBe(frRandom)
})
it('should handle root param reads conditional on another root param value', async () => {
// Visit en/us first — reads both lang and countryCode.
const browser = await next.browser('/en/us/conditional-on-root-param')
expect(await browser.elementById('lang-value').text()).toBe('en')
expect(await browser.elementById('country-code-value').text()).toBe('us')
const enUsRandom = await browser.elementById('random').text()
// Visit en/gb — also reads both. Must produce a different entry because
// countryCode differs.
await browser.loadPage(next.url + '/en/gb/conditional-on-root-param')
expect(await browser.elementById('lang-value').text()).toBe('en')
expect(await browser.elementById('country-code-value').text()).toBe('gb')
const enGbRandom = await browser.elementById('random').text()
expect(enGbRandom).not.toBe(enUsRandom)
// Visit fr/ca — only reads lang (not countryCode), but
// knownRootParamsByFunctionId already includes countryCode from the en
// visits. The key is more specific than needed, which is safe.
await browser.loadPage(next.url + '/fr/ca/conditional-on-root-param')
expect(await browser.elementById('lang-value').text()).toBe('fr')
expect(await browser.elementById('country-code-value').text()).toBe(
'null'
)
const frCaRandom = await browser.elementById('random').text()
expect(frCaRandom).not.toBe(enUsRandom)
expect(frCaRandom).not.toBe(enGbRandom)
// Revisit all three to confirm cache hits.
await browser.loadPage(next.url + '/en/us/conditional-on-root-param')
expect(await browser.elementById('random').text()).toBe(enUsRandom)
await browser.loadPage(next.url + '/en/gb/conditional-on-root-param')
expect(await browser.elementById('random').text()).toBe(enGbRandom)
await browser.loadPage(next.url + '/fr/ca/conditional-on-root-param')
expect(await browser.elementById('random').text()).toBe(frCaRandom)
})
if (!isNextDeploy) {
it('should error when using root params within `unstable_cache` - start', async () => {
await next.render$('/en/us/unstable_cache')
expect(next.cliOutput).toInclude(
"Error: Route /[lang]/[countryCode]/unstable_cache used `import('next/root-params').lang()` inside `unstable_cache`"
)
})
it('should error when using root params in "use cache" nested inside unstable_cache - start', async () => {
await next.render$('/en/us/nested-in-unstable_cache')
expect(next.cliOutput).toInclude(
'Error: Route /[lang]/[countryCode]/nested-in-unstable_cache used `import(\'next/root-params\').lang()` inside `"use cache"` nested within `unstable_cache`. Root params are not available in this context.'
)
})
}
}
})
describe('app-root-param-getters - private cache', () => {
const { next, isNextDev } = nextTestSetup({
files: join(__dirname, 'fixtures', 'use-cache-private'),
})
if (isNextDev) {
it('should allow using root params within a "use cache: private" - dev', async () => {
const browser = await next.browser('/en/us/use-cache-private')
await waitForNoRedbox(browser)
expect(await browser.elementById('param').text()).toBe('en us')
// A different set of root params must produce a separate entry. Since
// private caches are persisted in dev, this confirms the entry is keyed
// by root params and isn't reused for a different `[lang]/[locale]`.
await browser.loadPage(next.url + '/es/es/use-cache-private')
await waitForNoRedbox(browser)
expect(await browser.elementById('param').text()).toBe('es es')
})
} else {
it('should allow using root params within a "use cache: private" - start', async () => {
const browser = await next.browser('/en/us/use-cache-private')
expect(await browser.elementById('param').text()).toBe('en us')
})
}
})
describe('app-root-param-getters - cache - at build', () => {
const { next, isNextDev } = nextTestSetup({
files: join(__dirname, 'fixtures', 'use-cache-build'),
})
if (isNextDev) {
// we omit these tests in dev because they are duplicates semantically to the runtime fixture tested above
it('noop in dev', () => {})
} else {
it('should allow using root params within a "use cache" at build time', async () => {
const browser = await next.browser('/en/us/use-cache')
expect(await browser.elementById('param').text()).toBe('en us')
await browser.loadPage(next.url + '/es/es/use-cache')
expect(await browser.elementById('param').text()).toBe('es es')
})
}
})
describe('app-root-param-getters - cache dedup with root params', () => {
const { next, skipped, isNextDev } = nextTestSetup({
files: join(__dirname, 'fixtures', 'use-cache-dedup'),
// In deploy mode, concurrent requests could hit different lambdas.
skipDeployment: true,
})
if (skipped) return
it('should dedupe same root params and isolate different root params', async () => {
// Three concurrent requests: ca/en, ca/fr, ca/fr.
const [$en, $fr1, $fr2] = await Promise.all([
next.render$('/ca/en'),
next.render$('/ca/fr'),
next.render$('/ca/fr'),
])
const randomEn = $en('#random').text()
const randomFr1 = $fr1('#random').text()
const randomFr2 = $fr2('#random').text()
expect(randomEn).toBeTruthy()
expect(randomFr1).toBeTruthy()
// ca/en and ca/fr should have different results (isolation).
expect(randomEn).not.toBe(randomFr1)
// Both ca/fr requests should have the same result (deduped).
expect(randomFr1).toBe(randomFr2)
})
it('should dedupe same root params and isolate different root params for private caches', async () => {
// Three concurrent requests: ca/en, ca/fr, ca/fr.
const [$en, $fr1, $fr2] = await Promise.all([
next.render$('/ca/en/use-cache-private'),
next.render$('/ca/fr/use-cache-private'),
next.render$('/ca/fr/use-cache-private'),
])
const randomEn = $en('#random').text()
const randomFr1 = $fr1('#random').text()
const randomFr2 = $fr2('#random').text()
expect(randomEn).toBeTruthy()
expect(randomFr1).toBeTruthy()
// Different root params produce different entries, in dev and production.
expect(randomEn).not.toBe(randomFr1)
if (isNextDev) {
// In dev, private caches are persisted and participate in cross-request
// deduplication keyed by root params, so the two ca/fr requests join one
// in-flight invocation and share a single fill.
expect(randomFr1).toBe(randomFr2)
} else {
// In production, private caches are not persisted and are never deduped
// across requests, so each ca/fr request generates its own value.
expect(randomFr1).not.toBe(randomFr2)
}
})
})