Files
next.js/test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts
Hendrik LiebauandGitHub 1066fbf7a4 Improve deduping of concurrent 'use cache' invocations (#91830)
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
2026-04-17 18:31:22 +02:00

157 lines
5.2 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
const isoDateRegExp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
describe('use-cache-custom-handler', () => {
const { next, skipped, isNextStart } = nextTestSetup({
files: __dirname,
// Skip deployment so we can test the custom cache handlers log output
skipDeployment: true,
})
if (skipped) return
let outputIndex: number
beforeEach(() => {
outputIndex = next.cliOutput.length
})
it('should use a modern custom cache handler if provided', async () => {
const browser = await next.browser(`/`)
const initialData = await browser.elementById('data').text()
expect(initialData).toMatch(isoDateRegExp)
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).toContain('ModernCustomCacheHandler::refreshTags')
expect(next.cliOutput.slice(outputIndex)).toMatch(
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]+)","([0-9a-f]{2})+",\[\]\] \[ '_N_T_\/layout', '_N_T_\/page', '_N_T_\/', '_N_T_\/index' \]/
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
/ModernCustomCacheHandler::set \["(development|[A-Za-z0-9_-]+)","([0-9a-f]{2})+",\[\]\]/
)
// Since no existing cache entry was retrieved, we don't need to call
// getExpiration() to compare the cache entries timestamp with the
// expiration of the implicit tags.
expect(cliOutput).not.toContain(`ModernCustomCacheHandler::getExpiration`)
// The data should be cached initially.
outputIndex = next.cliOutput.length
await browser.refresh()
let data = await browser.elementById('data').text()
expect(data).toMatch(isoDateRegExp)
expect(data).toEqual(initialData)
// Now that a cache entry exists, we expect that getExpiration() is called
// to compare the cache entries timestamp with the expiration of the
// implicit tags.
expect(next.cliOutput.slice(outputIndex)).toContain(
`ModernCustomCacheHandler::getExpiration ["_N_T_/layout","_N_T_/page","_N_T_/","_N_T_/index"]`
)
// Because we use a low `revalidate` value for the "use cache" function, new
// data should be returned eventually.
await retry(
async () => {
await browser.refresh()
data = await browser.elementById('data').text()
expect(data).toMatch(isoDateRegExp)
expect(data).not.toEqual(initialData)
},
10_000,
2_000
)
})
it('calls neither refreshTags nor getExpiration if "use cache" is not used', async () => {
await next.fetch(`/no-cache`)
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).not.toContain('ModernCustomCacheHandler::refreshTags')
expect(cliOutput).not.toContain(`ModernCustomCacheHandler::getExpiration`)
})
it('should revalidate after redirect using a modern custom cache handler', async () => {
const browser = await next.browser(`/`)
const initialData = await browser.elementById('data').text()
expect(initialData).toMatch(isoDateRegExp)
await browser.elementById('revalidate-redirect').click()
await retry(async () => {
expect(next.cliOutput.slice(outputIndex)).toContain(
'ModernCustomCacheHandler::updateTags ["modern"]'
)
const data = await browser.elementById('data').text()
expect(data).toMatch(isoDateRegExp)
expect(data).not.toEqual(initialData)
}, 5000)
})
it('should not call updateTags for a normal invocation', async () => {
await next.fetch(`/`)
await retry(async () => {
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).toInclude('ModernCustomCacheHandler::refreshTags')
expect(cliOutput).not.toInclude('ModernCustomCacheHandler::updateTags')
})
})
it('should not call getExpiration after an action', async () => {
const browser = await next.browser(`/`)
outputIndex = next.cliOutput.length
await browser.elementById('revalidate-tag').click()
await retry(async () => {
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).not.toInclude('ModernCustomCacheHandler::getExpiration')
expect(cliOutput).toIncludeRepeated(
`ModernCustomCacheHandler::updateTags`,
1
)
})
})
if (isNextStart) {
it('should save a short-lived cache during prerendering at buildtime', async () => {
expect(next.cliOutput).toMatch(
/ModernCustomCacheHandler::set \["[A-Za-z0-9_-]+","([0-9a-f]{2})+",\[{"id":"dynamic-cache"}]\]/
)
})
}
it('should dedupe nested caches across different outer cache scopes, and still propagate cache life/tags correctly', async () => {
await next.fetch('/nested')
await retry(async () => {
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).toIncludeRepeated(
`ModernCustomCacheHandler::set-resolved-entry revalidate: 180, expire: 300, tags: inner`,
1
)
expect(cliOutput).toIncludeRepeated(
`ModernCustomCacheHandler::set-resolved-entry revalidate: 180, expire: 300, tags: outer1,inner`,
1
)
expect(cliOutput).toIncludeRepeated(
`ModernCustomCacheHandler::set-resolved-entry revalidate: 180, expire: 300, tags: outer2,inner`,
1
)
})
})
})