mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
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:
@@ -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', () => {
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
|
||||
}
|
||||
+51
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
|
||||
}
|
||||
+51
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user