mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +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.
357 lines
15 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|