Require explicit cacheLife on outer "use cache" when nesting short-lived caches (#89481)

This PR adds error handling for nested `"use cache"` where the inner
cache has a very short lifetime (zero `revalidate` or `expire` under 5
minutes) but the outer cache doesn't have an explicit `cacheLife()`
call.

Short-lived caches become "dynamic holes" that are excluded from
prerenders. When such a cache is nested inside another `"use cache"`
without an explicit `cacheLife`, the outer cache's lifetime would
silently become short too via
[propagation](https://nextjs.org/docs/app/api-reference/functions/cacheLife#nested-caching-behavior),
which can lead to unexpected behavior. To prevent this accidental
misconfiguration, Next.js now throws an error during prerendering,
requiring developers to explicitly declare their intent by adding
`cacheLife()` to the outer cache.

The implementation tracks whether `revalidate` and `expire` were
explicitly set via `hasExplicitRevalidate` and `hasExplicitExpire` flags
on the collected cache result. Errors are wrapped with
`wrapAsInvalidDynamicUsageError` to capture proper stack traces and
prevent userland try/catch from suppressing the build error.

Documentation has been updated with a new "Prerendering behavior"
section in the `cacheLife` API reference explaining how short-lived
caches become dynamic holes, and a "Nested short-lived caches"
subsection with examples showing how to fix the error.

closes NAR-761
This commit is contained in:
Hendrik Liebau
2026-02-05 16:39:08 +01:00
committed by GitHub
parent 2842212c29
commit 469e513c5f
15 changed files with 909 additions and 111 deletions
@@ -310,6 +310,8 @@ export default async function Page() {
}
```
> **Good to know:** A cache is considered "short-lived" when it uses the `seconds` profile, `revalidate: 0`, or `expire` under 5 minutes. Short-lived caches are automatically excluded from prerenders and become dynamic holes instead. If such a cache is nested inside another `use cache` without an explicit `cacheLife`, Next.js will throw an error during prerendering to prevent accidental misconfigurations. See [Prerendering behavior](/docs/app/api-reference/functions/cacheLife#prerendering-behavior) for details.
See the [`cacheLife` API reference](/docs/app/api-reference/functions/cacheLife) for available profiles and custom configuration options.
### With runtime data
@@ -245,6 +245,12 @@ When you call revalidation functions from a Server Action ([`revalidateTag`](/do
> **Good to know**: The `stale` property in `cacheLife` differs from [`staleTimes`](/docs/app/api-reference/config/next-config-js/staleTimes). While `staleTimes` is a global setting affecting all routes, `cacheLife` allows per-function or per-route configuration. Updating `staleTimes.static` also updates the `stale` value of the `default` cache profile.
### Prerendering behavior
Caches with very short lifetimes — zero `revalidate` or `expire` under 5 minutes — are automatically excluded from prerenders and become "dynamic holes" instead. This includes the `seconds` profile.
This behavior allows you to mix static and dynamic content within the same page. Static parts are prerendered, while short-lived caches create boundaries where data is fetched at request time rather than build time. Use a `<Suspense>` boundary around dynamic caches to provide a fallback while content loads.
## Examples
### Using preset profiles
@@ -420,6 +426,96 @@ export default async function Dashboard() {
**It is recommended to specify an explicit `cacheLife`.** With explicit lifetime values, you can inspect a cached function or component and immediately know its behavior without tracing through nested caches. Without explicit lifetime values, the behavior becomes dependent on inner cache lifetimes, making it harder to reason about.
#### Nested short-lived caches
As described in [Prerendering behavior](#prerendering-behavior), short-lived caches (zero `revalidate` or `expire` under 5 minutes) become dynamic holes excluded from prerenders.
When a short-lived cache is nested inside another `use cache` without an explicit `cacheLife`, the outer cache's lifetime would silently become short too via propagation. To prevent this accidental misconfiguration, Next.js throws an error during prerendering.
Note that the nested cache may not be obvious — it could be in an imported module or even a third-party dependency:
```tsx filename="components/short-lived-widget.tsx" highlight={5}
import { cacheLife } from 'next/cache'
export async function ShortLivedWidget() {
'use cache'
cacheLife('seconds')
const data = await fetchRealtimeData()
return <div>{data}</div>
}
```
Using this component from another `use cache` without an explicit `cacheLife` will error during prerendering:
```tsx filename="app/page.tsx"
import { ShortLivedWidget } from '@/components/short-lived-widget'
export default async function Page() {
'use cache'
// Error: no explicit cacheLife on outer cache
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```
To fix the error, add an explicit `cacheLife()` to the outer `use cache`:
**If you want the outer cache to remain static (prerendered)**, set a longer cache lifetime:
```tsx filename="app/page.tsx" highlight={6}
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'
export default async function Page() {
'use cache'
cacheLife('default') // Explicit cacheLife prevents the error
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```
**If you want the outer cache to also be short-lived**, explicitly set a short cache lifetime to confirm this is intentional. Wrap the component in a `<Suspense>` boundary to provide a fallback while content loads:
```tsx filename="app/page.tsx" highlight={7,17-19}
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'
async function Content() {
'use cache: remote'
cacheLife('seconds') // Explicit cacheLife confirms this is intentionally short-lived
return (
<>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</>
)
}
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<Content />
</Suspense>
</div>
)
}
```
> **Note:** This example uses `"use cache: remote"` because runtime caching in serverless deployments doesn't persist across requests with the default in-memory cache. For self-hosted environments, `"use cache"` may be sufficient. See [Runtime caching considerations](/docs/app/api-reference/directives/use-cache#runtime-caching-considerations) for more details.
### Conditional cache lifetimes
You can call `cacheLife` conditionally in different code paths to set different cache durations based on your application logic:
@@ -0,0 +1,106 @@
---
title: Nested `"use cache"` with short cache lifetime requires explicit `cacheLife` on outer cache
---
## Why This Error Occurred
A `"use cache"` function or component with a very short cache lifetime (either `revalidate: 0` or `expire` under 5 minutes) is nested inside another `"use cache"` that doesn't have an explicit `cacheLife()` call.
When a nested cache has a very short lifetime, it would normally create a "dynamic hole" - meaning it's excluded from static prerenders. However, when this happens inside another `"use cache"` without an explicit `cacheLife`, the outer cache's lifetime silently becomes very short too (via propagation), which may be unintentional.
To prevent accidental misconfigurations, Next.js requires you to explicitly declare your intent by adding `cacheLife()` to the outer `"use cache"`.
## Possible Ways to Fix It
Add an explicit `cacheLife()` call to the outer `"use cache"` to declare your intent.
### Before
```jsx filename="components/short-lived-widget.js"
import { cacheLife } from 'next/cache'
export async function ShortLivedWidget() {
'use cache'
cacheLife('seconds')
const data = await fetchRealtimeData()
return <div>{data}</div>
}
```
```jsx filename="app/page.js"
import { ShortLivedWidget } from '@/components/short-lived-widget'
export default async function Page() {
'use cache'
// Error: no explicit cacheLife on outer cache
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```
### After: If you want the outer cache to remain static (prerendered)
Set a longer cache lifetime on the outer cache:
```jsx filename="app/page.js" highlight={6}
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'
export default async function Page() {
'use cache'
cacheLife('default') // Explicit cacheLife prevents the error
return (
<div>
<h1>Dashboard</h1>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</div>
)
}
```
### After: If you want the outer cache to also be short-lived
Explicitly set a short cache lifetime on the outer cache to confirm this is intentional. Wrap the component in a `<Suspense>` boundary to provide a fallback while content loads:
```jsx filename="app/page.js" highlight={7,17-19}
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { ShortLivedWidget } from '@/components/short-lived-widget'
async function Content() {
'use cache: remote'
cacheLife('seconds') // Explicit cacheLife confirms this is intentionally short-lived
return (
<>
<p>Last updated: {new Date().toISOString()}</p>
<ShortLivedWidget />
</>
)
}
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<Content />
</Suspense>
</div>
)
}
```
> **Note:** This example uses `"use cache: remote"` because runtime caching in serverless deployments doesn't persist across requests with the default in-memory cache. For self-hosted environments, `"use cache"` may be sufficient. See [Runtime caching considerations](/docs/app/api-reference/directives/use-cache#runtime-caching-considerations) for more details.
## Useful Links
- [`cacheLife` function](/docs/app/api-reference/functions/cacheLife)
- [`"use cache"` directive](/docs/app/api-reference/directives/use-cache)
- [Prerendering behavior](/docs/app/api-reference/functions/cacheLife#prerendering-behavior)
- [Nested short-lived caches](/docs/app/api-reference/functions/cacheLife#nested-short-lived-caches)
@@ -34,12 +34,16 @@ describe('getDynamicHTMLPostponedState', () => {
prerenderResumeDataCache.cache.set(
'1',
Promise.resolve({
value: streamFromString('hello'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
entry: {
value: streamFromString('hello'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
},
hasExplicitRevalidate: true,
hasExplicitExpire: true,
})
)
@@ -80,7 +84,7 @@ describe('getDynamicHTMLPostponedState', () => {
expect(value).toBeDefined()
await expect(streamToString(value!.value)).resolves.toEqual('hello')
await expect(streamToString(value!.entry.value)).resolves.toEqual('hello')
})
it('serializes a HTML postponed state without fallback params', async () => {
@@ -2,9 +2,9 @@ import {
arrayBufferToString,
stringToUint8Array,
} from '../app-render/encryption-utils'
import type { CacheEntry } from '../lib/cache-handlers/types'
import type { CachedFetchValue } from '../response-cache/types'
import { DYNAMIC_EXPIRE } from '../use-cache/constants'
import type { CollectedCacheResult } from '../use-cache/use-cache-wrapper'
/**
* A generic cache store type that provides a subset of Map functionality
@@ -34,19 +34,23 @@ export type DecryptedBoundArgsCacheStore = CacheStore<string>
* Serialized format for "use cache" entries
*/
export interface UseCacheCacheStoreSerialized {
value: string
tags: string[]
stale: number
timestamp: number
expire: number
revalidate: number
entry: {
value: string
tags: string[]
stale: number
timestamp: number
expire: number
revalidate: number
}
hasExplicitRevalidate: boolean | undefined
hasExplicitExpire: boolean | undefined
}
/**
* A cache store specifically for "use cache" values that stores promises of
* cache entries.
* collected cache results (entry + metadata).
*/
export type UseCacheCacheStore = CacheStore<Promise<CacheEntry>>
export type UseCacheCacheStore = CacheStore<Promise<CollectedCacheResult>>
/**
* Parses serialized cache entries into a UseCacheCacheStore
@@ -56,30 +60,34 @@ export type UseCacheCacheStore = CacheStore<Promise<CacheEntry>>
export function parseUseCacheCacheStore(
entries: Iterable<[string, UseCacheCacheStoreSerialized]>
): UseCacheCacheStore {
const store = new Map<string, Promise<CacheEntry>>()
const store = new Map<string, Promise<CollectedCacheResult>>()
for (const [
key,
{ value, tags, stale, timestamp, expire, revalidate },
{ entry, hasExplicitRevalidate, hasExplicitExpire },
] of entries) {
store.set(
key,
Promise.resolve({
// Create a ReadableStream from the Uint8Array
value: new ReadableStream<Uint8Array>({
start(controller) {
// Enqueue the Uint8Array to the stream
controller.enqueue(stringToUint8Array(atob(value)))
entry: {
// Create a ReadableStream from the Uint8Array
value: new ReadableStream<Uint8Array>({
start(controller) {
// Enqueue the Uint8Array to the stream
controller.enqueue(stringToUint8Array(atob(entry.value)))
// Close the stream
controller.close()
},
}),
tags,
stale,
timestamp,
expire,
revalidate,
// Close the stream
controller.close()
},
}),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
},
hasExplicitRevalidate,
hasExplicitExpire,
})
)
}
@@ -93,13 +101,13 @@ export function parseUseCacheCacheStore(
* @returns A promise that resolves to an array of key-value pairs with serialized values
*/
export async function serializeUseCacheCacheStore(
entries: IterableIterator<[string, Promise<CacheEntry>]>,
entries: IterableIterator<[string, Promise<CollectedCacheResult>]>,
isCacheComponentsEnabled: boolean
): Promise<Array<[string, UseCacheCacheStoreSerialized] | null>> {
return Promise.all(
Array.from(entries).map(([key, value]) => {
return value
.then(async (entry) => {
.then(async ({ entry, hasExplicitRevalidate, hasExplicitExpire }) => {
if (
isCacheComponentsEnabled &&
(entry.revalidate === 0 || entry.expire < DYNAMIC_EXPIRE)
@@ -124,13 +132,17 @@ export async function serializeUseCacheCacheStore(
return [
key,
{
// Encode the value as a base64 string.
value: btoa(binaryString),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
entry: {
// Encode the value as a base64 string.
value: btoa(binaryString),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
},
hasExplicitRevalidate,
hasExplicitExpire,
},
] satisfies [string, UseCacheCacheStoreSerialized]
})
@@ -15,12 +15,16 @@ function createMockedCache() {
cache.cache.set(
'success',
Promise.resolve({
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
entry: {
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 1,
},
hasExplicitRevalidate: true,
hasExplicitExpire: true,
})
)
@@ -28,12 +32,16 @@ function createMockedCache() {
cache.cache.set(
'dynamic-expire',
Promise.resolve({
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 299,
revalidate: 1,
entry: {
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 299,
revalidate: 1,
},
hasExplicitRevalidate: true,
hasExplicitExpire: true,
})
)
@@ -41,12 +49,16 @@ function createMockedCache() {
cache.cache.set(
'zero-revalidate',
Promise.resolve({
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 0,
entry: {
value: streamFromString('value'),
tags: [],
stale: 0,
timestamp: 0,
expire: 300,
revalidate: 0,
},
hasExplicitRevalidate: true,
hasExplicitExpire: true,
})
)
@@ -89,7 +101,7 @@ describe('stringifyResumeDataCache', () => {
)
} else {
expect(decompressed).toMatchInlineSnapshot(
`"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":1},"dynamic-expire":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":299,"revalidate":1},"zero-revalidate":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":0}},"encryptedBoundArgs":{}}}"`
`"{"store":{"fetch":{},"cache":{"success":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":1},"hasExplicitRevalidate":true,"hasExplicitExpire":true},"dynamic-expire":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":299,"revalidate":1},"hasExplicitRevalidate":true,"hasExplicitExpire":true},"zero-revalidate":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":0},"hasExplicitRevalidate":true,"hasExplicitExpire":true}},"encryptedBoundArgs":{}}}"`
)
}
})
@@ -117,7 +129,7 @@ describe('stringifyResumeDataCache', () => {
)
} else {
expect(decompressed).toMatchInlineSnapshot(
`"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":1},"dynamic-expire":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":299,"revalidate":1},"zero-revalidate":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":0}},"encryptedBoundArgs":{}}}"`
`"{"store":{"fetch":{},"cache":{"success":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":1},"hasExplicitRevalidate":true,"hasExplicitExpire":true},"dynamic-expire":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":299,"revalidate":1},"hasExplicitRevalidate":true,"hasExplicitExpire":true},"zero-revalidate":{"entry":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":300,"revalidate":0},"hasExplicitRevalidate":true,"hasExplicitExpire":true}},"encryptedBoundArgs":{}}}"`
)
}
})
@@ -131,6 +131,22 @@ const findSourceMapURL =
.findSourceMapURLDEV
: undefined
const nestedCacheZeroRevalidateErrorMessage =
`A "use cache" with zero \`revalidate\` is nested inside another "use cache" ` +
`that has no explicit \`cacheLife\`, which is not allowed during ` +
`prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose ` +
`whether it should be prerendered (with non-zero \`revalidate\`) or remain ` +
`dynamic (with zero \`revalidate\`). Read more: ` +
`https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife`
const nestedCacheShortExpireErrorMessage =
`A "use cache" with short \`expire\` (under 5 minutes) is nested inside ` +
`another "use cache" that has no explicit \`cacheLife\`, which is not ` +
`allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` ` +
`to choose whether it should be prerendered (with longer \`expire\`) or remain ` +
`dynamic (with short \`expire\`). Read more: ` +
`https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife`
function generateCacheEntry(
workStore: WorkStore,
cacheContext: CacheContext,
@@ -381,6 +397,26 @@ function propagateCacheLifeAndTags(
}
}
export interface CollectedCacheResult {
entry: CacheEntry
/**
* Whether the revalidate value was explicitly set via `cacheLife()`.
* - `true`: explicitly set
* - `false`: implicit (propagated from a nested cache or implicitly using the
* default profile)
* - `undefined`: unknown (e.g. pre-existing entry from a cache handler)
*/
hasExplicitRevalidate: boolean | undefined
/**
* Whether the expire value was explicitly set via `cacheLife()`.
* - `true`: explicitly set
* - `false`: implicit (propagated from a nested cache or implicitly using the
* default profile)
* - `undefined`: unknown (e.g. pre-existing entry from a cache handler)
*/
hasExplicitExpire: boolean | undefined
}
async function collectResult(
savedStream: ReadableStream<Uint8Array>,
workStore: WorkStore,
@@ -388,7 +424,7 @@ async function collectResult(
innerCacheStore: UseCacheStore,
startTime: number,
errors: Array<unknown> // This is a live array that gets pushed into.
): Promise<CacheEntry> {
): Promise<CollectedCacheResult> {
// We create a buffered stream that collects all chunks until the end to
// ensure that RSC has finished rendering and therefore we have collected
// all tags. In the future the RSC API might allow for the equivalent of
@@ -458,7 +494,7 @@ async function collectResult(
if (cacheContext.outerWorkUnitStore) {
const outerWorkUnitStore = cacheContext.outerWorkUnitStore
// Propagate cache life & tags to the parent context if appropriate.
// Propagate cache life & tags to the outer context if appropriate.
switch (outerWorkUnitStore.type) {
case 'prerender':
case 'prerender-runtime': {
@@ -503,14 +539,18 @@ async function collectResult(
}
}
return entry
return {
entry,
hasExplicitRevalidate: innerCacheStore.explicitRevalidate !== undefined,
hasExplicitExpire: innerCacheStore.explicitExpire !== undefined,
}
}
type GenerateCacheEntryResult =
| {
readonly type: 'cached'
readonly stream: ReadableStream
readonly pendingCacheEntry: Promise<CacheEntry>
readonly pendingCacheResult: Promise<CollectedCacheResult>
}
| {
readonly type: 'prerender-dynamic'
@@ -728,7 +768,7 @@ async function generateCacheEntryImpl(
const [returnStream, savedStream] = stream.tee()
const pendingCacheEntry = collectResult(
const pendingCacheResult = collectResult(
savedStream,
workStore,
cacheContext,
@@ -749,7 +789,7 @@ async function generateCacheEntryImpl(
// erroring we cannot return a stale-if-error version but it allows
// streaming back the result earlier.
stream: returnStream,
pendingCacheEntry,
pendingCacheResult,
}
}
@@ -767,17 +807,35 @@ function cloneCacheEntry(entry: CacheEntry): [CacheEntry, CacheEntry] {
return [entry, clonedEntry]
}
async function clonePendingCacheEntry(
pendingCacheEntry: Promise<CacheEntry>
): Promise<[CacheEntry, CacheEntry]> {
const entry = await pendingCacheEntry
return cloneCacheEntry(entry)
function cloneCacheResult(
result: CollectedCacheResult
): [CollectedCacheResult, CollectedCacheResult] {
const [entryA, entryB] = cloneCacheEntry(result.entry)
return [
{
entry: entryA,
hasExplicitRevalidate: result.hasExplicitRevalidate,
hasExplicitExpire: result.hasExplicitExpire,
},
{
entry: entryB,
hasExplicitRevalidate: result.hasExplicitRevalidate,
hasExplicitExpire: result.hasExplicitExpire,
},
]
}
async function getNthCacheEntry(
split: Promise<[CacheEntry, CacheEntry]>,
async function clonePendingCacheResult(
pendingCacheResult: Promise<CollectedCacheResult>
): Promise<[CollectedCacheResult, CollectedCacheResult]> {
const result = await pendingCacheResult
return cloneCacheResult(result)
}
async function getNthCacheResult(
split: Promise<[CollectedCacheResult, CollectedCacheResult]>,
i: number
): Promise<CacheEntry> {
): Promise<CollectedCacheResult> {
return (await split)[i]
}
@@ -1237,17 +1295,17 @@ export async function cache(
if (cacheSignal) {
cacheSignal.beginRead()
}
const cachedEntry = renderResumeDataCache.cache.get(serializedCacheKey)
if (cachedEntry !== undefined) {
let existingEntry: CacheEntry | undefined = await cachedEntry
const cachedResult = renderResumeDataCache.cache.get(serializedCacheKey)
if (cachedResult !== undefined) {
let existingResult: CollectedCacheResult | undefined = await cachedResult
// Check if the RDC entry should be discarded due to recently revalidated tags.
// When a server action calls updateTag(), the re-render should see fresh data
// instead of stale RDC data.
if (existingEntry !== undefined) {
if (existingResult !== undefined) {
const implicitTags = workUnitStore?.implicitTags?.tags ?? []
if (
existingEntry.tags.some((tag) =>
existingResult.entry.tags.some((tag) =>
isRecentlyRevalidatedTag(tag, workStore)
) ||
implicitTags.some((tag) => isRecentlyRevalidatedTag(tag, workStore))
@@ -1256,14 +1314,14 @@ export async function cache(
'discarding RDC entry due to recently revalidated tags',
serializedCacheKey
)
existingEntry = undefined
existingResult = undefined
}
}
if (workUnitStore !== undefined && existingEntry !== undefined) {
if (workUnitStore !== undefined && existingResult !== undefined) {
if (
existingEntry.revalidate === 0 ||
existingEntry.expire < DYNAMIC_EXPIRE
existingResult.entry.revalidate === 0 ||
existingResult.entry.expire < DYNAMIC_EXPIRE
) {
switch (workUnitStore.type) {
case 'prerender':
@@ -1273,18 +1331,30 @@ export async function cache(
// generating static pages for such data. It's better to leave
// a dynamic hole that can be filled in during the resume with
// a potentially cached entry.
if (existingEntry.revalidate === 0) {
if (existingResult.entry.revalidate === 0) {
if (existingResult.hasExplicitRevalidate === false) {
throw wrapAsInvalidDynamicUsageError(
new Error(nestedCacheZeroRevalidateErrorMessage),
workStore
)
}
debug?.(
'omitting entry',
serializedCacheKey,
'from static shell due to revalidate: 0'
)
} else {
if (existingResult.hasExplicitExpire === false) {
throw wrapAsInvalidDynamicUsageError(
new Error(nestedCacheShortExpireErrorMessage),
workStore
)
}
debug?.(
'omitting entry',
serializedCacheKey,
'from static shell due to short expire value:',
existingEntry.expire
existingResult.entry.expire
)
}
if (cacheSignal) {
@@ -1306,6 +1376,24 @@ export async function cache(
}
case 'request': {
if (process.env.NODE_ENV === 'development') {
if (
existingResult.entry.revalidate === 0 &&
existingResult.hasExplicitRevalidate === false
) {
throw wrapAsInvalidDynamicUsageError(
new Error(nestedCacheZeroRevalidateErrorMessage),
workStore
)
}
if (
existingResult.entry.expire < DYNAMIC_EXPIRE &&
existingResult.hasExplicitExpire === false
) {
throw wrapAsInvalidDynamicUsageError(
new Error(nestedCacheShortExpireErrorMessage),
workStore
)
}
// We delay the cache here so that it doesn't resolve in the static task --
// in a regular static prerender, it'd be a hanging promise, and we need to reflect that,
// so it has to resolve later.
@@ -1331,7 +1419,7 @@ export async function cache(
}
}
if (existingEntry.stale < RUNTIME_PREFETCH_DYNAMIC_STALE) {
if (existingResult.entry.stale < RUNTIME_PREFETCH_DYNAMIC_STALE) {
switch (workUnitStore.type) {
case 'prerender-runtime':
// In a runtime prerender, if the cache entry will become
@@ -1342,7 +1430,7 @@ export async function cache(
'omitting entry',
serializedCacheKey,
'from runtime shell due to short stale value:',
existingEntry.stale
existingResult.entry.stale
)
if (cacheSignal) {
cacheSignal.endRead()
@@ -1381,20 +1469,20 @@ export async function cache(
}
}
if (existingEntry !== undefined) {
if (existingResult !== undefined) {
debug?.('Resume Data Cache entry found', serializedCacheKey)
if (prerenderResumeDataCache) {
prerenderResumeDataCache.cache.set(serializedCacheKey, cachedEntry)
prerenderResumeDataCache.cache.set(serializedCacheKey, cachedResult)
}
// We want to make sure we only propagate cache life & tags if the
// entry was *not* omitted from the prerender. So we only do this
// after the above early returns.
propagateCacheLifeAndTags(cacheContext, existingEntry)
propagateCacheLifeAndTags(cacheContext, existingResult.entry)
const [streamA, streamB] = existingEntry.value.tee()
existingEntry.value = streamB
const [streamA, streamB] = existingResult.entry.value.tee()
existingResult.entry.value = streamB
if (cacheSignal) {
// When we have a cacheSignal we need to block on reading the cache
@@ -1629,26 +1717,29 @@ export async function cache(
return result.hangingPromise
}
const { stream: newStream, pendingCacheEntry } = result
const { stream: newStream, pendingCacheResult } = result
// When draft mode is enabled, we must not save the cache entry.
if (!workStore.isDraftMode) {
let savedCacheEntry
let savedCacheResult
if (prerenderResumeDataCache) {
// Create a clone that goes into the cache scope memory cache.
const split = clonePendingCacheEntry(pendingCacheEntry)
savedCacheEntry = getNthCacheEntry(split, 0)
const split = clonePendingCacheResult(pendingCacheResult)
savedCacheResult = getNthCacheResult(split, 0)
prerenderResumeDataCache.cache.set(
serializedCacheKey,
getNthCacheEntry(split, 1)
getNthCacheResult(split, 1)
)
} else {
savedCacheEntry = pendingCacheEntry
savedCacheResult = pendingCacheResult
}
if (cacheHandler) {
const promise = cacheHandler.set(serializedCacheKey, savedCacheEntry)
const promise = cacheHandler.set(
serializedCacheKey,
savedCacheResult.then((r) => r.entry)
)
workStore.pendingRevalidateWrites ??= []
workStore.pendingRevalidateWrites.push(promise)
@@ -1682,7 +1773,17 @@ export async function cache(
prerenderResumeDataCache.cache.set(
serializedCacheKey,
Promise.resolve(entryRight)
Promise.resolve({
entry: entryRight,
// For pre-existing entries from cache handlers we don't know
// whether they had explicit cache life values or not. But we only
// need this information during prerendering when we produce new
// entries, where the cache life of an inner cache may be propagated
// to the outer one. In that case we use the RDC. So it's safe to
// set this to undefined here.
hasExplicitRevalidate: undefined,
hasExplicitExpire: undefined,
})
)
} else {
// If we're not regenerating we need to signal that we've finished
@@ -1706,24 +1807,24 @@ export async function cache(
)
if (result.type === 'cached') {
const { stream: ignoredStream, pendingCacheEntry } = result
let savedCacheEntry: Promise<CacheEntry>
const { stream: ignoredStream, pendingCacheResult } = result
let savedCacheResult: Promise<CollectedCacheResult>
if (prerenderResumeDataCache) {
const split = clonePendingCacheEntry(pendingCacheEntry)
savedCacheEntry = getNthCacheEntry(split, 0)
const split = clonePendingCacheResult(pendingCacheResult)
savedCacheResult = getNthCacheResult(split, 0)
prerenderResumeDataCache.cache.set(
serializedCacheKey,
getNthCacheEntry(split, 1)
getNthCacheResult(split, 1)
)
} else {
savedCacheEntry = pendingCacheEntry
savedCacheResult = pendingCacheResult
}
if (cacheHandler) {
const promise = cacheHandler.set(
serializedCacheKey,
savedCacheEntry
savedCacheResult.then((r) => r.entry)
)
workStore.pendingRevalidateWrites ??= []
@@ -2578,6 +2578,7 @@ describe('Cache Components Errors', () => {
})
}
})
describe('slow cache', () => {
if (isNextDev) {
it('should show a redbox error', async () => {
@@ -2709,6 +2710,108 @@ describe('Cache Components Errors', () => {
})
}
})
describe('nested', () => {
if (isNextDev) {
it('should show a redbox error', async () => {
const browser = await next.browser('/use-cache-low-expire/nested')
await expect(browser).toDisplayRedbox(`
{
"description": "A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/use-cache-low-expire/nested/page.tsx (20:14) @ async Page
> 20 | result = await outerCache()
| ^",
"stack": [
"async Page app/use-cache-low-expire/nested/page.tsx (20:14)",
],
}
`)
})
} else {
it('should error the build', async () => {
try {
await prerender('/use-cache-low-expire/nested')
} catch {
// we expect the build to fail
}
const output = getPrerenderOutput(
next.cliOutput.slice(cliOutputLength),
{ isMinified: !isDebugPrerender }
)
if (isTurbopack) {
if (isDebugPrerender) {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async Page (app/use-cache-low-expire/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/use-cache-low-expire/nested" in your browser to investigate the error.
Error occurred prerendering page "/use-cache-low-expire/nested". Read more: https://nextjs.org/docs/messages/prerender-error
> Export encountered errors on following paths:
/use-cache-low-expire/nested/page: /use-cache-low-expire/nested"
`)
} else {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async k (app/use-cache-low-expire/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running \`next dev\`, then open "/use-cache-low-expire/nested" in your browser to investigate the error.
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.
Error occurred prerendering page "/use-cache-low-expire/nested". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /use-cache-low-expire/nested/page: /use-cache-low-expire/nested, exiting the build."
`)
}
} else {
if (isDebugPrerender) {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async Page (webpack:///app/use-cache-low-expire/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/use-cache-low-expire/nested" in your browser to investigate the error.
Error occurred prerendering page "/use-cache-low-expire/nested". Read more: https://nextjs.org/docs/messages/prerender-error
> Export encountered errors on following paths:
/use-cache-low-expire/nested/page: /use-cache-low-expire/nested"
`)
} else {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at a (<next-dist-dir>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running \`next dev\`, then open "/use-cache-low-expire/nested" in your browser to investigate the error.
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.
Error occurred prerendering page "/use-cache-low-expire/nested". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /use-cache-low-expire/nested/page: /use-cache-low-expire/nested, exiting the build."
`)
}
}
})
}
})
})
describe('cacheLife with revalidate: 0', () => {
@@ -2843,6 +2946,7 @@ describe('Cache Components Errors', () => {
})
}
})
describe('slow cache', () => {
if (isNextDev) {
it('should show a redbox error', async () => {
@@ -2974,6 +3078,110 @@ describe('Cache Components Errors', () => {
})
}
})
describe('nested', () => {
if (isNextDev) {
it('should show a redbox error', async () => {
const browser = await next.browser(
'/use-cache-revalidate-0/nested'
)
await expect(browser).toDisplayRedbox(`
{
"description": "A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/use-cache-revalidate-0/nested/page.tsx (20:14) @ async Page
> 20 | result = await outerCache()
| ^",
"stack": [
"async Page app/use-cache-revalidate-0/nested/page.tsx (20:14)",
],
}
`)
})
} else {
it('should error the build', async () => {
try {
await prerender('/use-cache-revalidate-0/nested')
} catch {
// we expect the build to fail
}
const output = getPrerenderOutput(
next.cliOutput.slice(cliOutputLength),
{ isMinified: !isDebugPrerender }
)
if (isTurbopack) {
if (isDebugPrerender) {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async Page (app/use-cache-revalidate-0/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/use-cache-revalidate-0/nested" in your browser to investigate the error.
Error occurred prerendering page "/use-cache-revalidate-0/nested". Read more: https://nextjs.org/docs/messages/prerender-error
> Export encountered errors on following paths:
/use-cache-revalidate-0/nested/page: /use-cache-revalidate-0/nested"
`)
} else {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async k (app/use-cache-revalidate-0/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running \`next dev\`, then open "/use-cache-revalidate-0/nested" in your browser to investigate the error.
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.
Error occurred prerendering page "/use-cache-revalidate-0/nested". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /use-cache-revalidate-0/nested/page: /use-cache-revalidate-0/nested, exiting the build."
`)
}
} else {
if (isDebugPrerender) {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at async Page (webpack:///app/use-cache-revalidate-0/nested/page.tsx:20:14)
18 | let result: number | undefined
19 | try {
> 20 | result = await outerCache()
| ^
21 | } catch {}
22 |
23 | return (
To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/use-cache-revalidate-0/nested" in your browser to investigate the error.
Error occurred prerendering page "/use-cache-revalidate-0/nested". Read more: https://nextjs.org/docs/messages/prerender-error
> Export encountered errors on following paths:
/use-cache-revalidate-0/nested/page: /use-cache-revalidate-0/nested"
`)
} else {
expect(output).toMatchInlineSnapshot(`
"Error: A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
at a (<next-dist-dir>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running \`next dev\`, then open "/use-cache-revalidate-0/nested" in your browser to investigate the error.
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.
Error occurred prerendering page "/use-cache-revalidate-0/nested". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /use-cache-revalidate-0/nested/page: /use-cache-revalidate-0/nested, exiting the build."
`)
}
}
})
}
})
})
describe('reading fallback params', () => {
@@ -0,0 +1,5 @@
import { Suspense } from 'react'
export default function Root({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
}
@@ -0,0 +1,51 @@
import { cacheLife } from 'next/cache'
async function innerCache() {
'use cache'
cacheLife({ expire: 60 }) // 1 minute, under the 5 minute threshold
return Math.random()
}
async function outerCache() {
'use cache'
// Explicitly not setting a `cacheLife` here means this will use the implicit
// default cache life, i.e. the shortest cache life of any nested 'use cache'
// will be applied, or the values of the 'default' profile if none are nested.
return innerCache()
}
export default async function Page() {
let result: number | undefined
try {
result = await outerCache()
} catch {}
return (
<>
<p>
This page tests that a nested "use cache" with low expire time inside
another "use cache" without explicit cacheLife throws an error during
prerendering.
</p>
<p>
The inner cache function is cached with a 60 second expire time. Such a
short-lived cache would normally create a dynamic hole and be excluded
from prerenders. However, when nested inside another 'use cache' that
doesn't specify an explicit `cacheLife`, this will error during
prerendering, instead of silently creating a dynamic hole. This is to
prevent accidental misconfigurations, where a developer may forget to
set an explicit `cacheLife` on an outer 'use cache' boundary, not
knowing that a nested 'use cache' is using a short-lived cache, which
would degrade the outer 'use cache' to a dynamic hole. If there is an
outer suspense boundary, this might not be noticeable, so we error
during prerendering to make sure the developer is aware of the situation
and picks an explicit `cacheLife` for the outer 'use cache'.
</p>
<p>
This page also tests that the error cannot be caught by userland code
(the try/catch above should NOT suppress the build error).
</p>
<p>Result: {result}</p>
</>
)
}
@@ -0,0 +1,5 @@
import { Suspense } from 'react'
export default function Root({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
}
@@ -0,0 +1,51 @@
import { cacheLife } from 'next/cache'
async function innerCache() {
'use cache'
cacheLife({ revalidate: 0 })
return Math.random()
}
async function outerCache() {
'use cache'
// Explicitly not setting a `cacheLife` here means this will use the implicit
// default cache life, i.e. the shortest cache life of any nested 'use cache'
// will be applied, or the values of the 'default' profile if none are nested.
return innerCache()
}
export default async function Page() {
let result: number | undefined
try {
result = await outerCache()
} catch {}
return (
<>
<p>
This page tests that a nested "use cache" with zero revalidate inside
another "use cache" without explicit cacheLife throws an error during
prerendering.
</p>
<p>
The inner cache function is cached with a zero revalidate time. Such a
short-lived cache would normally create a dynamic hole and be excluded
from prerenders. However, when nested inside another 'use cache' that
doesn't specify an explicit `cacheLife`, this will error during
prerendering, instead of silently creating a dynamic hole. This is to
prevent accidental misconfigurations, where a developer may forget to
set an explicit `cacheLife` on an outer 'use cache' boundary, not
knowing that a nested 'use cache' is using a short-lived cache, which
would degrade the outer 'use cache' to a dynamic hole. If there is an
outer suspense boundary, this might not be noticeable, so we error
during prerendering to make sure the developer is aware of the situation
and picks an explicit `cacheLife` for the outer 'use cache'.
</p>
<p>
This page also tests that the error cannot be caught by userland code
(the try/catch above should NOT suppress the build error).
</p>
<p>Result: {result}</p>
</>
)
}
@@ -4,7 +4,7 @@ async function getData() {
'use cache'
return fetch('https://next-data-api-endpoint.vercel.app/api/random', {
next: { revalidate: 0 },
next: { revalidate: 1 },
}).then((res) => res.text())
}
@@ -0,0 +1,95 @@
import { connection } from 'next/server'
import { cacheLife } from 'next/cache'
import { Suspense } from 'react'
async function revalidateZero() {
'use cache: remote'
cacheLife({ revalidate: 0 })
return new Date().toISOString()
}
async function lowExpire() {
'use cache: remote'
cacheLife({ expire: 5 })
return new Date().toISOString()
}
async function OuterCacheNoExplicit() {
'use cache: remote'
// No explicit cacheLife - this would error during prerendering, but is
// allowed at request time (after connection()).
return (
<>
<p>
<code>revalidate=0</code>:{' '}
<span id="revalidate-zero">{await revalidateZero()}</span>
</p>
<p>
<code>expire=5</code>: <span id="low-expire">{await lowExpire()}</span>
</p>
</>
)
}
async function OuterCacheExplicitShort() {
'use cache: remote'
// Explicit short cacheLife - excluded from prerender, becomes a dynamic hole.
cacheLife({ revalidate: 0, expire: 5 })
return (
<>
<p>
Explicit <code>revalidate=0</code>:{' '}
<span id="explicit-revalidate-zero">{await revalidateZero()}</span>
</p>
<p>
Explicit <code>expire=5</code>:{' '}
<span id="explicit-low-expire">{await lowExpire()}</span>
</p>
</>
)
}
async function OuterCacheExplicitLong() {
'use cache: remote'
// Explicit long cacheLife - included in prerender despite short-lived inner
// caches.
cacheLife('default')
return (
<>
<p>
Explicit long (<code>revalidate=0</code> inner):{' '}
<span id="explicit-long-revalidate-zero">{await revalidateZero()}</span>
</p>
<p>
Explicit long (<code>expire=5</code> inner):{' '}
<span id="explicit-long-low-expire">{await lowExpire()}</span>
</p>
</>
)
}
async function Dynamic() {
await connection()
return <OuterCacheNoExplicit />
}
export default async function Page() {
return (
<>
<p id="static">Static content</p>
<Suspense fallback={<p id="dynamic">Loading...</p>}>
<Dynamic />
</Suspense>
<Suspense fallback={<p>Loading explicit short...</p>}>
<OuterCacheExplicitShort />
</Suspense>
<OuterCacheExplicitLong />
</>
)
}
+52 -2
View File
@@ -486,6 +486,7 @@ describe('use-cache', () => {
'/directive-in-node-modules/without-handler',
'/draft-mode/with-cookies',
'/draft-mode/without-cookies',
'/fetch-revalidate',
'/form',
'/imported-from-client',
'/logs',
@@ -639,9 +640,15 @@ describe('use-cache', () => {
const browser = await next.browser('/fetch-revalidate')
const initialValue = await browser.elementByCss('#random').text()
await browser.refresh()
expect(await browser.elementByCss('#random').text()).not.toBe(initialValue)
// Revalidate is set to 1 second, so after waiting the value should change.
await retry(async () => {
await browser.refresh()
expect(await browser.elementByCss('#random').text()).not.toBe(
initialValue
)
})
})
it('should cache fetch without no-store', async () => {
@@ -1435,6 +1442,49 @@ describe('use-cache', () => {
})
})
}
it('should allow nested short-lived caches after connection()', async () => {
// Check the prerendered shell (no JS).
let browser = await next.browser('/short-lived-caches', {
disableJavaScript: true,
})
// Static content should be in the shell.
expect(await browser.elementById('static').text()).toBe('Static content')
// Explicit long cacheLife should be in the shell despite short-lived inner
// caches.
expect(
await browser.elementById('explicit-long-revalidate-zero').text()
).toBeDateString()
expect(
await browser.elementById('explicit-long-low-expire').text()
).toBeDateString()
// Now check with JS enabled to verify dynamic content loads.
browser = await next.browser('/short-lived-caches', {
pushErrorAsConsoleLog: true,
})
// Dynamic content should eventually render.
await retry(async () => {
// No explicit outer cacheLife (after connection()).
expect(
await browser.elementById('revalidate-zero').text()
).toBeDateString()
expect(await browser.elementById('low-expire').text()).toBeDateString()
// Explicit short cacheLife - excluded from prerender.
expect(
await browser.elementById('explicit-revalidate-zero').text()
).toBeDateString()
expect(
await browser.elementById('explicit-low-expire').text()
).toBeDateString()
})
await assertNoConsoleErrors(browser)
})
})
async function getSanitizedLogs(browser: Playwright): Promise<string[]> {