Skip to content

Commit ff34f00

Browse files
committed
Show inner "use cache" as cause of nested-dynamic cache error
When a `"use cache"` propagated a dynamic cache life (`revalidate: 0` or `expire` under 5 minutes) to a parent without an explicit `cacheLife`, the resulting error pointed only at the outer cache invocation. With the inner cache's call site missing, tracing which nested cache was responsible meant reading through the outer's body — fine when it's local code, much harder when the dynamism comes from a nested cache buried in a third-party dependency. This change attaches the inner invocation as `cause` of the error, so the dev redbox and the build log show two stacks: the outer that threw, and the inner that propagated the dynamic life. The inner call site has to be captured eagerly while `cache()` is still on the synchronous stack, because we only learn whether the inner resolved dynamic asynchronously — after `collectResult` finishes and `propagateCacheEntryMetadata` runs — and by then the inner's frames are no longer on the JS stack. We only construct the eager `Error` when the parent is itself a public `"use cache"` (the only case where this entry could become a propagated origin), so top-level caches skip the allocation. The eager `Error` is held on `cacheContext.dynamicNestedCacheError`; once propagation knows the inner resolved dynamic, it's copied onto the outer store's same-named field, then carried through the outer's own `collectResult` into its RDC entry — which the throw site finally reads back as `cause`. We keep the first dynamic child — the immediate origin from the throwing cache's perspective. The two nested-dynamic cache error messages also get a small cleanup: each used to write `"use cache"` two different ways within the same sentence (bare and backticked); both now write it the same way.
1 parent 6d90d31 commit ff34f00

8 files changed

Lines changed: 254 additions & 29 deletions

File tree

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,5 +1190,8 @@
11901190
"1189": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.",
11911191
"1190": "Route \"%s\" accessed param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
11921192
"1191": "Route \"%s\" called %s but param%s %s %s not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. %s requires all route params to be provided.",
1193-
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object."
1193+
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
1194+
"1193": "This \"use cache\" has a dynamic cache life that was propagated to its parent.",
1195+
"1194": "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",
1196+
"1195": "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"
11941197
}

packages/next/src/server/app-render/postponed-state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('getDynamicHTMLPostponedState', () => {
4545
hasExplicitRevalidate: true,
4646
hasExplicitExpire: true,
4747
readRootParamNames: undefined,
48+
dynamicNestedCacheError: undefined,
4849
})
4950
)
5051

packages/next/src/server/app-render/work-unit-async-storage.external.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,13 @@ export interface PublicUseCacheStore extends CommonUseCacheStore {
364364
* Tracks which root param names were read during this cache invocation.
365365
*/
366366
readonly readRootParamNames: Set<string>
367+
/**
368+
* The first nested public `'use cache'` invocation with a dynamic cache life
369+
* (`revalidate === 0` or `expire < DYNAMIC_EXPIRE`) that propagated up to
370+
* this store. Used as `cause` for the nested-dynamic cache error so the
371+
* redbox can point at the inner invocation site, not just the outer one.
372+
*/
373+
dynamicNestedCacheError: Error | undefined
367374
}
368375

369376
export interface PrivateUseCacheStore extends CommonUseCacheStore {

packages/next/src/server/resume-data-cache/cache-store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export function parseUseCacheCacheStore(
9292
readRootParamNames: readRootParamNames
9393
? new Set(readRootParamNames)
9494
: undefined,
95+
// Serialized RDC entries are non-dynamic by construction (the
96+
// serializer drops dynamic entries), so this is never produced from the
97+
// wire — the throw path that consumes it is only reachable for dynamic
98+
// entries, which only exist in the in-memory RDC.
99+
dynamicNestedCacheError: undefined,
95100
})
96101
)
97102
}

packages/next/src/server/resume-data-cache/resume-data-cache.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function createMockedCache() {
2626
hasExplicitRevalidate: true,
2727
hasExplicitExpire: true,
2828
readRootParamNames: undefined,
29+
dynamicNestedCacheError: undefined,
2930
})
3031
)
3132

@@ -44,6 +45,7 @@ function createMockedCache() {
4445
hasExplicitRevalidate: true,
4546
hasExplicitExpire: true,
4647
readRootParamNames: undefined,
48+
dynamicNestedCacheError: undefined,
4749
})
4850
)
4951

@@ -62,6 +64,7 @@ function createMockedCache() {
6264
hasExplicitRevalidate: true,
6365
hasExplicitExpire: true,
6466
readRootParamNames: undefined,
67+
dynamicNestedCacheError: undefined,
6568
})
6669
)
6770

packages/next/src/server/use-cache/use-cache-errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,18 @@ export class UseCacheDeadlockError extends Error {
1313
)
1414
}
1515
}
16+
17+
/**
18+
* Used purely as `cause` for the nested-dynamic cache error: its captured stack
19+
* points at the inner `"use cache"` invocation that propagated a dynamic cache
20+
* life up to the outer cache. Constructed eagerly in `cache()` while the caller
21+
* is still on the synchronous stack — see use-cache-wrapper.ts.
22+
*/
23+
export class NestedDynamicUseCacheError extends Error {
24+
constructor() {
25+
super(
26+
'This "use cache" has a dynamic cache life that was propagated to its parent.'
27+
)
28+
this.name = 'Nested dynamic "use cache"'
29+
}
30+
}

packages/next/src/server/use-cache/use-cache-wrapper.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import { DYNAMIC_EXPIRE, RUNTIME_PREFETCH_DYNAMIC_STALE } from './constants'
5959
import { NEXT_CACHE_ROOT_PARAM_TAG_ID } from '../../lib/constants'
6060
import type { CacheHandler } from '../lib/cache-handlers/types'
6161
import { getCacheHandler } from './handlers'
62-
import { UseCacheDeadlockError, UseCacheTimeoutError } from './use-cache-errors'
62+
import {
63+
NestedDynamicUseCacheError,
64+
UseCacheDeadlockError,
65+
UseCacheTimeoutError,
66+
} from './use-cache-errors'
6367
import {
6468
createHangingInputAbortSignal,
6569
postponeWithTracking,
@@ -106,6 +110,15 @@ interface PublicCacheContext {
106110
readonly functionId: string
107111
/** The cache handler kind (first arg of `cache()`, e.g. 'default'). */
108112
readonly handlerKind: string
113+
/**
114+
* Eagerly captured at `cache()` entry, pointing at this invocation's call
115+
* site. Only set when the outer is itself a public `'use cache'` (i.e. when
116+
* this entry could become the propagated origin of a nested-dynamic cache
117+
* error in the parent). When this cache resolves dynamic, this is copied into
118+
* `outerWorkUnitStore.dynamicNestedCacheError` so the parent's error can use
119+
* it as `cause`.
120+
*/
121+
readonly dynamicNestedCacheError: Error | undefined
109122
}
110123

111124
type CacheContext = PrivateCacheContext | PublicCacheContext
@@ -148,6 +161,7 @@ interface CacheResultMetadata {
148161
readonly readRootParamNames: ReadonlySet<string> | undefined
149162
readonly hasExplicitRevalidate: boolean | undefined
150163
readonly hasExplicitExpire: boolean | undefined
164+
readonly dynamicNestedCacheError: Error | undefined
151165
}
152166

153167
/**
@@ -268,15 +282,15 @@ const findSourceMapURL =
268282
const nestedCacheZeroRevalidateErrorMessage =
269283
`A "use cache" with zero \`revalidate\` is nested inside another "use cache" ` +
270284
`that has no explicit \`cacheLife\`, which is not allowed during ` +
271-
`prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose ` +
285+
`prerendering. Add \`cacheLife()\` to the outer "use cache" to choose ` +
272286
`whether it should be prerendered (with non-zero \`revalidate\`) or remain ` +
273287
`dynamic (with zero \`revalidate\`). Read more: ` +
274288
`https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife`
275289

276290
const nestedCacheShortExpireErrorMessage =
277291
`A "use cache" with short \`expire\` (under 5 minutes) is nested inside ` +
278292
`another "use cache" that has no explicit \`cacheLife\`, which is not ` +
279-
`allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` ` +
293+
`allowed during prerendering. Add \`cacheLife()\` to the outer "use cache" ` +
280294
`to choose whether it should be prerendered (with longer \`expire\`) or remain ` +
281295
`dynamic (with short \`expire\`). Read more: ` +
282296
`https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife`
@@ -381,6 +395,7 @@ function saveSharedCacheEntryToResumeDataCache(
381395
readRootParamNames: metadata.readRootParamNames,
382396
hasExplicitRevalidate: metadata.hasExplicitRevalidate,
383397
hasExplicitExpire: metadata.hasExplicitExpire,
398+
dynamicNestedCacheError: metadata.dynamicNestedCacheError,
384399
}))
385400

386401
prerenderResumeDataCache.cache.set(serializedCacheKey, rdcResult)
@@ -585,6 +600,7 @@ function createUseCacheStore(
585600
rootParams: outerWorkUnitStore.rootParams,
586601
readRootParamNames: new Set<string>(),
587602
outerOwnerStack: cacheContext.outerOwnerStack,
603+
dynamicNestedCacheError: undefined,
588604
}
589605
}
590606
}
@@ -767,6 +783,17 @@ function propagateCacheEntryMetadata(
767783
cacheContext.outerWorkUnitStore.readRootParamNames.add(paramName)
768784
}
769785
}
786+
// If this entry's cache life is dynamic, record this invocation as the
787+
// origin to use as `cause` when the outer cache surfaces the
788+
// nested-dynamic cache error. `??=` keeps the first occurrence so the
789+
// cause points at the immediate dynamic child.
790+
if (
791+
cacheContext.dynamicNestedCacheError !== undefined &&
792+
(metadata.revalidate === 0 || metadata.expire < DYNAMIC_EXPIRE)
793+
) {
794+
cacheContext.outerWorkUnitStore.dynamicNestedCacheError ??=
795+
cacheContext.dynamicNestedCacheError
796+
}
770797
// fallthrough
771798
case 'private-cache':
772799
case 'prerender':
@@ -874,6 +901,15 @@ export interface CollectedCacheResult {
874901
* don't have this information.
875902
*/
876903
readRootParamNames: ReadonlySet<string> | undefined
904+
/**
905+
* The `Error` carried up from the first nested public `'use cache'`
906+
* invocation that propagated a dynamic cache life into this entry, captured
907+
* eagerly at that inner invocation's `cache()` entry. Used as `cause` for the
908+
* nested-dynamic cache error so the redbox can point at the inner invocation
909+
* site, not just the outer one. Lives in-memory only — intentionally dropped
910+
* from the serialized RDC because dynamic entries aren't serialized either.
911+
*/
912+
dynamicNestedCacheError: Error | undefined
877913
}
878914

879915
async function collectResult(
@@ -958,6 +994,12 @@ async function collectResult(
958994
innerCacheStore.type === 'cache'
959995
? innerCacheStore.readRootParamNames
960996
: undefined,
997+
// The store accumulates this from nested public caches that propagated a
998+
// dynamic life into us.
999+
dynamicNestedCacheError:
1000+
innerCacheStore.type === 'cache'
1001+
? innerCacheStore.dynamicNestedCacheError
1002+
: undefined,
9611003
}
9621004

9631005
if (!cacheContext.skipPropagation) {
@@ -970,6 +1012,7 @@ async function collectResult(
9701012
hasExplicitRevalidate: collected.hasExplicitRevalidate,
9711013
hasExplicitExpire: collected.hasExplicitExpire,
9721014
readRootParamNames: collected.readRootParamNames,
1015+
dynamicNestedCacheError: collected.dynamicNestedCacheError,
9731016
})
9741017

9751018
const cacheSignal = getCacheSignal(cacheContext.outerWorkUnitStore)
@@ -1338,12 +1381,14 @@ function cloneCacheResult(
13381381
hasExplicitRevalidate: result.hasExplicitRevalidate,
13391382
hasExplicitExpire: result.hasExplicitExpire,
13401383
readRootParamNames: result.readRootParamNames,
1384+
dynamicNestedCacheError: result.dynamicNestedCacheError,
13411385
},
13421386
{
13431387
entry: entryB,
13441388
hasExplicitRevalidate: result.hasExplicitRevalidate,
13451389
hasExplicitExpire: result.hasExplicitExpire,
13461390
readRootParamNames: result.readRootParamNames,
1391+
dynamicNestedCacheError: result.dynamicNestedCacheError,
13471392
},
13481393
]
13491394
}
@@ -1560,12 +1605,36 @@ export async function cache(
15601605
throw new InvariantError(
15611606
`${expression} must not be used within a client component. Next.js should be preventing ${expression} from being allowed in client components statically, but did not in this case.`
15621607
)
1608+
case 'cache': {
1609+
// Eagerly capture this invocation's call site while still synchronous
1610+
// in `cache()`. Used as `cause` of the nested-dynamic cache error
1611+
// when the outer cache (whose body never re-runs during the final
1612+
// prerender) throws. Only constructed when the parent is itself a
1613+
// public `'use cache'` — otherwise this entry can never propagate
1614+
// dynamism into that error and the allocation would be wasted. Private
1615+
// parents are intentionally excluded: `'use cache: private'` is
1616+
// dynamic-by-definition in prerendering and deferred to the runtime
1617+
// stage in dev requests, so a public cache nested inside one never
1618+
// triggers the throw upstream.
1619+
const dynamicNestedCacheError = new NestedDynamicUseCacheError()
1620+
Error.captureStackTrace(dynamicNestedCacheError, cache)
1621+
applyOwnerStack(dynamicNestedCacheError)
1622+
cacheContext = {
1623+
kind: 'public',
1624+
outerWorkUnitStore: workUnitStore,
1625+
skipPropagation: false,
1626+
outerOwnerStack,
1627+
functionId: id,
1628+
handlerKind: kind,
1629+
dynamicNestedCacheError,
1630+
}
1631+
break
1632+
}
15631633
case 'prerender':
15641634
case 'prerender-runtime':
15651635
case 'prerender-ppr':
15661636
case 'prerender-legacy':
15671637
case 'request':
1568-
case 'cache':
15691638
case 'private-cache':
15701639
// TODO: We should probably forbid nesting "use cache" inside
15711640
// unstable_cache. (fallthrough)
@@ -1578,6 +1647,7 @@ export async function cache(
15781647
outerOwnerStack,
15791648
functionId: id,
15801649
handlerKind: kind,
1650+
dynamicNestedCacheError: undefined,
15811651
}
15821652
break
15831653
default:
@@ -1988,7 +2058,9 @@ export async function cache(
19882058
if (rdcResult.entry.revalidate === 0) {
19892059
if (rdcResult.hasExplicitRevalidate === false) {
19902060
throw wrapAsInvalidDynamicUsageError(
1991-
new Error(nestedCacheZeroRevalidateErrorMessage)
2061+
new Error(nestedCacheZeroRevalidateErrorMessage, {
2062+
cause: rdcResult.dynamicNestedCacheError,
2063+
})
19922064
)
19932065
}
19942066
debug?.(
@@ -1999,7 +2071,9 @@ export async function cache(
19992071
} else {
20002072
if (rdcResult.hasExplicitExpire === false) {
20012073
throw wrapAsInvalidDynamicUsageError(
2002-
new Error(nestedCacheShortExpireErrorMessage)
2074+
new Error(nestedCacheShortExpireErrorMessage, {
2075+
cause: rdcResult.dynamicNestedCacheError,
2076+
})
20032077
)
20042078
}
20052079
debug?.(
@@ -2036,15 +2110,19 @@ export async function cache(
20362110
rdcResult.hasExplicitRevalidate === false
20372111
) {
20382112
throw wrapAsInvalidDynamicUsageError(
2039-
new Error(nestedCacheZeroRevalidateErrorMessage)
2113+
new Error(nestedCacheZeroRevalidateErrorMessage, {
2114+
cause: rdcResult.dynamicNestedCacheError,
2115+
})
20402116
)
20412117
}
20422118
if (
20432119
rdcResult.entry.expire < DYNAMIC_EXPIRE &&
20442120
rdcResult.hasExplicitExpire === false
20452121
) {
20462122
throw wrapAsInvalidDynamicUsageError(
2047-
new Error(nestedCacheShortExpireErrorMessage)
2123+
new Error(nestedCacheShortExpireErrorMessage, {
2124+
cause: rdcResult.dynamicNestedCacheError,
2125+
})
20482126
)
20492127
}
20502128
// We delay the cache here so that it doesn't resolve in the static task --
@@ -2154,6 +2232,7 @@ export async function cache(
21542232
hasExplicitRevalidate: rdcResult.hasExplicitRevalidate,
21552233
hasExplicitExpire: rdcResult.hasExplicitExpire,
21562234
readRootParamNames: rdcResult.readRootParamNames,
2235+
dynamicNestedCacheError: rdcResult.dynamicNestedCacheError,
21572236
})
21582237

21592238
const [streamA, streamB] = rdcResult.entry.value.tee()
@@ -2685,6 +2764,7 @@ export async function cache(
26852764
hasExplicitRevalidate: collected.hasExplicitRevalidate,
26862765
hasExplicitExpire: collected.hasExplicitExpire,
26872766
readRootParamNames: collected.readRootParamNames,
2767+
dynamicNestedCacheError: collected.dynamicNestedCacheError,
26882768
}))
26892769

26902770
const sharedCacheEntry = new SharedCacheEntry(
@@ -2720,6 +2800,8 @@ export async function cache(
27202800
// set this to undefined here.
27212801
hasExplicitRevalidate: undefined,
27222802
hasExplicitExpire: undefined,
2803+
// The same applies to the dynamic nested cache error.
2804+
dynamicNestedCacheError: undefined,
27232805
}
27242806

27252807
maybePropagateCacheEntryMetadata(cacheContext, entryMetadata)
@@ -2746,6 +2828,7 @@ export async function cache(
27462828
hasExplicitRevalidate: entryMetadata.hasExplicitRevalidate,
27472829
hasExplicitExpire: entryMetadata.hasExplicitExpire,
27482830
readRootParamNames: entryMetadata.readRootParamNames,
2831+
dynamicNestedCacheError: entryMetadata.dynamicNestedCacheError,
27492832
})
27502833
)
27512834
} else {

0 commit comments

Comments
 (0)