mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
With #71286, we implemented deduping of cache entries under certain circumstances. For example, the following constructed example was fixed with the PR: ```js async function getCachedRandom() { 'use cache' return Math.random() } const rand1 = await getCachedRandom() const rand2 = await getCachedRandom() assert(rand1 === rand2) ``` However, this implementation relied on awaiting the two calls sequentially. When rendering components, this can usually not be guaranteed. E.g. the following example was not properly deduped: ```jsx async function Cached() { 'use cache' return <p>{Math.random()}</p> } export default function Page() { return ( <> <Cached /> <Cached /> </> ) } ``` This did render the same value, but only because we triggered two render passes, and the last cached value was used for both elements in the final render pass. But during the first render pass, the `Cached` function was called twice, and the cache entry was also set twice. With #75786, we also fixed the render scenario by wrapping the cached function in `React.cache`. This however did not work for route handlers. E.g. the first example rewritten as follows, and used in a route handler, still wouldn't be deduped: ```js const [rand1, rand2] = await Promise.all([ getCachedRandom(), getCachedRandom(), ]) ``` Furthermore, with this solution, nested cached functions could not be deduped across different outer cache scopes. This is because each cache scope creates its own `React.cache` scope. Example: ```jsx async function Inner() { 'use cache' return <p>{Math.random()}</p> } async function Outer1() { 'use cache' return <Inner /> } async function Outer2() { 'use cache' return <Inner /> } export default function Page() { return ( <> <Outer1 /> <Outer2 /> </> ) } ``` This PR introduces two-layer invocation tracking that deduplicates these cases without changing how cache handlers are implemented. Only the first invocation (the "leader") performs the cache handler lookup and generation. Subsequent invocations ("joiners") tee the leader's result stream instead. The deduplication scope starts after the Resume Data Cache (RDC) lookup and before the cache handler `get`. The RDC phase is excluded because it throws synchronous errors (dynamic usage errors) that need individual stack traces per call site, and RDC lookups are local with no network savings from deduplication. Intra-request deduplication is stored on the `WorkStore` and keyed by `serializedCacheKey` (the coarse key, which is safe because root params are identical within a single request). Cross-request deduplication is stored in a module-scope map keyed by `cacheHandlerKey`, which may include root params on the warm path. Cross-request joiners must await metadata for root param verification before forking the stream. If the key mismatches (different root params), the joiner retries with a recomputed key. Because metadata is checked before `fork()` is called, a mismatched joiner never consumes a stream from the wrong entry. Stream tee-ing is lazy via the `SharedCacheEntry` class: the leader calls `fork()` to get its copy, and each joiner calls `fork()` on demand. If no joiners exist, only one tee occurs. The `SharedCacheResult` discriminated union wraps either a `SharedCacheEntry` (for the cached case) or a hanging promise (for `prerender-dynamic`). Each invocation still decodes the stream independently via `createFromReadableStream` with its own `temporaryReferences`, because sharing the decoded result would cause cache poisoning when components receive different non-serializable props (e.g. `children`). Admittedly, this adds significant complexity to the `cache()` function. Two classes help keep it manageable: - `SharedCacheEntry` encapsulates stream ownership and lazy tee-ing so that call sites don't need to reason about which streams have been consumed or need cloning. - `ResolvableSharedCacheResult` manages the deferred promise, map registration, and lazy cleanup. Entries stay in the dedup maps until collection completes (so late-arriving invocations can join while the leader streams), then clean up automatically on resolve or reject. A follow-up refactoring to extract the cache handler lookup and generation into a separate function would help break up the function's size. closes #78703
320 lines
13 KiB
TypeScript
320 lines
13 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')
|
|
})
|
|
} 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 } = 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)
|
|
})
|
|
})
|