Skip to content

Commit d1ee0f0

Browse files
authored
Prevent "use cache" timeout errors from being caught in userland code (#78998)
When `dynamicIO` is enabled and a `"use cache"` function accesses dynamic request APIs, we fail the prerendering with a timeout error after 50 seconds. This error could be swallowed in userland code however, when the cached function is wrapped in a try/catch block. That's not the intended behavior, so we now fail the prerendering (or dynamic validation in dev mode) with the timeout error in this case as well, using the same approach as in #77838. This also works around a bug that led to the timeout errors not being source-mapped correctly with Turbopack. In a future PR, we will adapt the behaviour for prerendering of fallback shells that are allowed to be empty, in which case the timeout must not fail the build.
1 parent 66acc83 commit d1ee0f0

File tree

10 files changed

+238
-125
lines changed

10 files changed

+238
-125
lines changed

packages/next/src/server/app-render/app-render.tsx

+26-32
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ import {
187187
} from '../resume-data-cache/resume-data-cache'
188188
import type { MetadataErrorType } from '../../lib/metadata/resolve-metadata'
189189
import isError from '../../lib/is-error'
190-
import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors'
191190
import { createServerInsertedMetadata } from './metadata-insertion/create-server-inserted-metadata'
192191
import { getPreviouslyRevalidatedTags } from '../server-utils'
193192
import { executeRevalidates } from '../revalidation-utils'
@@ -632,7 +631,6 @@ async function generateDynamicFlightRenderResult(
632631
ctx,
633632
false,
634633
ctx.clientReferenceManifest,
635-
ctx.workStore.route,
636634
requestStore
637635
)
638636
}
@@ -1390,7 +1388,6 @@ async function renderToHTMLOrFlightImpl(
13901388
res,
13911389
ctx,
13921390
metadata,
1393-
workStore,
13941391
loaderTree
13951392
)
13961393

@@ -1410,8 +1407,8 @@ async function renderToHTMLOrFlightImpl(
14101407

14111408
// If we encountered any unexpected errors during build we fail the
14121409
// prerendering phase and the build.
1413-
if (workStore.invalidUsageError) {
1414-
throw workStore.invalidUsageError
1410+
if (workStore.invalidDynamicUsageError) {
1411+
throw workStore.invalidDynamicUsageError
14151412
}
14161413
if (response.digestErrorsMap.size) {
14171414
const buildFailingError = response.digestErrorsMap.values().next().value
@@ -1564,7 +1561,6 @@ async function renderToHTMLOrFlightImpl(
15641561
req,
15651562
res,
15661563
ctx,
1567-
workStore,
15681564
notFoundLoaderTree,
15691565
formState,
15701566
postponedState
@@ -1591,14 +1587,13 @@ async function renderToHTMLOrFlightImpl(
15911587
req,
15921588
res,
15931589
ctx,
1594-
workStore,
15951590
loaderTree,
15961591
formState,
15971592
postponedState
15981593
)
15991594

1600-
if (workStore.invalidUsageError) {
1601-
throw workStore.invalidUsageError
1595+
if (workStore.invalidDynamicUsageError) {
1596+
throw workStore.invalidDynamicUsageError
16021597
}
16031598

16041599
// If we have pending revalidates, wait until they are all resolved.
@@ -1727,7 +1722,6 @@ async function renderToStream(
17271722
req: BaseNextRequest,
17281723
res: BaseNextResponse,
17291724
ctx: AppRenderContext,
1730-
workStore: WorkStore,
17311725
tree: LoaderTree,
17321726
formState: any,
17331727
postponedState: PostponedState | null
@@ -1873,7 +1867,6 @@ async function renderToStream(
18731867
ctx,
18741868
res.statusCode === 404,
18751869
clientReferenceManifest,
1876-
workStore.route,
18771870
requestStore
18781871
)
18791872

@@ -2220,10 +2213,9 @@ async function spawnDynamicValidationInDev(
22202213
ctx: AppRenderContext,
22212214
isNotFound: boolean,
22222215
clientReferenceManifest: NonNullable<RenderOpts['clientReferenceManifest']>,
2223-
route: string,
22242216
requestStore: RequestStore
22252217
): Promise<void> {
2226-
const { componentMod: ComponentMod, implicitTags } = ctx
2218+
const { componentMod: ComponentMod, implicitTags, workStore } = ctx
22272219
const rootParams = getRootParams(
22282220
ComponentMod.tree,
22292221
ctx.getDynamicParamFromSegment
@@ -2317,7 +2309,7 @@ async function spawnDynamicValidationInDev(
23172309
process.env.NEXT_DEBUG_BUILD ||
23182310
process.env.__NEXT_VERBOSE_LOGGING
23192311
) {
2320-
printDebugThrownValueForProspectiveRender(err, route)
2312+
printDebugThrownValueForProspectiveRender(err, workStore.route)
23212313
}
23222314
},
23232315
signal: initialServerRenderController.signal,
@@ -2335,7 +2327,7 @@ async function spawnDynamicValidationInDev(
23352327
) {
23362328
// We don't normally log these errors because we are going to retry anyway but
23372329
// it can be useful for debugging Next.js itself to get visibility here when needed
2338-
printDebugThrownValueForProspectiveRender(err, route)
2330+
printDebugThrownValueForProspectiveRender(err, workStore.route)
23392331
}
23402332
}
23412333

@@ -2375,7 +2367,7 @@ async function spawnDynamicValidationInDev(
23752367
) {
23762368
// We don't normally log these errors because we are going to retry anyway but
23772369
// it can be useful for debugging Next.js itself to get visibility here when needed
2378-
printDebugThrownValueForProspectiveRender(err, route)
2370+
printDebugThrownValueForProspectiveRender(err, workStore.route)
23792371
}
23802372
},
23812373
}
@@ -2387,7 +2379,7 @@ async function spawnDynamicValidationInDev(
23872379
// We're going to retry to so we normally would suppress this error but
23882380
// when verbose logging is on we print it
23892381
if (process.env.__NEXT_VERBOSE_LOGGING) {
2390-
printDebugThrownValueForProspectiveRender(err, route)
2382+
printDebugThrownValueForProspectiveRender(err, workStore.route)
23912383
}
23922384
}
23932385
})
@@ -2467,10 +2459,6 @@ async function spawnDynamicValidationInDev(
24672459
clientReferenceManifest.clientModules,
24682460
{
24692461
onError: (err) => {
2470-
if (isUseCacheTimeoutError(err)) {
2471-
return err.digest
2472-
}
2473-
24742462
if (
24752463
finalServerController.signal.aborted &&
24762464
isPrerenderInterruptedError(err)
@@ -2511,12 +2499,6 @@ async function spawnDynamicValidationInDev(
25112499
{
25122500
signal: finalClientController.signal,
25132501
onError: (err, errorInfo) => {
2514-
if (isUseCacheTimeoutError(err)) {
2515-
dynamicValidation.dynamicErrors.push(err)
2516-
2517-
return
2518-
}
2519-
25202502
if (
25212503
isPrerenderInterruptedError(err) ||
25222504
finalClientController.signal.aborted
@@ -2531,7 +2513,7 @@ async function spawnDynamicValidationInDev(
25312513
const componentStack = errorInfo.componentStack
25322514
if (typeof componentStack === 'string') {
25332515
trackAllowedDynamicAccess(
2534-
route,
2516+
workStore.route,
25352517
componentStack,
25362518
dynamicValidation
25372519
)
@@ -2572,7 +2554,7 @@ async function spawnDynamicValidationInDev(
25722554
// track any dynamic access that occurs above the suspense boundary because
25732555
// we'll do so in the route shell.
25742556
throwIfDisallowedDynamic(
2575-
route,
2557+
workStore,
25762558
preludeIsEmpty,
25772559
dynamicValidation,
25782560
serverDynamicTracking,
@@ -2611,7 +2593,6 @@ async function prerenderToStream(
26112593
res: BaseNextResponse,
26122594
ctx: AppRenderContext,
26132595
metadata: AppPageRenderResultMetadata,
2614-
workStore: WorkStore,
26152596
tree: LoaderTree
26162597
): Promise<PrerenderToStreamResult> {
26172598
// When prerendering formState is always null. We still include it
@@ -2626,6 +2607,7 @@ async function prerenderToStream(
26262607
nonce,
26272608
pagePath,
26282609
renderOpts,
2610+
workStore,
26292611
} = ctx
26302612

26312613
const rootParams = getRootParams(tree, getDynamicParamFromSegment)
@@ -2840,6 +2822,12 @@ async function prerenderToStream(
28402822
initialServerRenderController.abort()
28412823
initialServerPrerenderController.abort()
28422824

2825+
// We don't need to continue the prerender process if we already
2826+
// detected invalid dynamic usage in the initial prerender phase.
2827+
if (workStore.invalidDynamicUsageError) {
2828+
throw workStore.invalidDynamicUsageError
2829+
}
2830+
28432831
let initialServerResult
28442832
try {
28452833
initialServerResult = await createReactServerPrerenderResult(
@@ -3106,7 +3094,7 @@ async function prerenderToStream(
31063094
// we'll do so in the route shell.
31073095
if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
31083096
throwIfDisallowedDynamic(
3109-
workStore.route,
3097+
workStore,
31103098
preludeIsEmpty,
31113099
dynamicValidation,
31123100
serverDynamicTracking,
@@ -3428,6 +3416,12 @@ async function prerenderToStream(
34283416
initialServerRenderController.abort()
34293417
initialServerPrerenderController.abort()
34303418

3419+
// We don't need to continue the prerender process if we already
3420+
// detected invalid dynamic usage in the initial prerender phase.
3421+
if (workStore.invalidDynamicUsageError) {
3422+
throw workStore.invalidDynamicUsageError
3423+
}
3424+
34313425
// We've now filled caches and triggered any inadvertant sync bailouts
34323426
// due to lazy module initialization. We can restart our render to capture results
34333427

@@ -3591,7 +3585,7 @@ async function prerenderToStream(
35913585
if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
35923586
// We don't have a shell because the root errored when we aborted.
35933587
throwIfDisallowedDynamic(
3594-
workStore.route,
3588+
workStore,
35953589
preludeIsEmpty,
35963590
dynamicValidation,
35973591
serverDynamicTracking,

packages/next/src/server/app-render/dynamic-rendering.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -651,12 +651,17 @@ function createErrorWithComponentStack(
651651
}
652652

653653
export function throwIfDisallowedDynamic(
654-
route: string,
654+
workStore: WorkStore,
655655
hasEmptyShell: boolean,
656656
dynamicValidation: DynamicValidationState,
657657
serverDynamic: DynamicTrackingState,
658658
clientDynamic: DynamicTrackingState
659659
): void {
660+
if (workStore.invalidDynamicUsageError) {
661+
console.error(workStore.invalidDynamicUsageError)
662+
throw new StaticGenBailoutError()
663+
}
664+
660665
if (hasEmptyShell) {
661666
if (dynamicValidation.hasSuspenseAboveBody) {
662667
// This route has opted into allowing fully dynamic rendering
@@ -698,7 +703,7 @@ export function throwIfDisallowedDynamic(
698703
// to indicate your are ok with fully dynamic rendering.
699704
if (dynamicValidation.hasDynamicViewport) {
700705
console.error(
701-
`Route "${route}" has a \`generateViewport\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
706+
`Route "${workStore.route}" has a \`generateViewport\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
702707
)
703708
throw new StaticGenBailoutError()
704709
}
@@ -708,7 +713,7 @@ export function throwIfDisallowedDynamic(
708713
dynamicValidation.hasDynamicMetadata
709714
) {
710715
console.error(
711-
`Route "${route}" has a \`generateMetadata\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) when the rest of the route does not. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
716+
`Route "${workStore.route}" has a \`generateMetadata\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) when the rest of the route does not. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
712717
)
713718
throw new StaticGenBailoutError()
714719
}

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

+4-3
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,13 @@ export interface WorkStore {
5151
dynamicUsageStack?: string
5252

5353
/**
54-
* Invalid usage errors might be caught in userland. We attach them to the
55-
* work store to ensure we can still fail the build or dev render.
54+
* Invalid dynamic usage errors might be caught in userland. We attach them to
55+
* the work store to ensure we can still fail the build, or show en error in
56+
* dev mode.
5657
*/
5758
// TODO: Collect an array of errors, and throw as AggregateError when
5859
// `serializeError` and the Dev Overlay support it.
59-
invalidUsageError?: Error
60+
invalidDynamicUsageError?: Error
6061

6162
nextFetchId?: number
6263
pathWasRevalidated?: boolean

packages/next/src/server/request/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function throwForSearchParamsAccessInUseCache(
2929
)
3030

3131
Error.captureStackTrace(error, constructorOpt)
32-
workStore.invalidUsageError ??= error
32+
workStore.invalidDynamicUsageError ??= error
3333

3434
throw error
3535
}

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

+5-16
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ async function collectResult(
253253
let idx = 0
254254
const bufferStream = new ReadableStream({
255255
pull(controller) {
256-
if (workStore.invalidUsageError) {
257-
controller.error(workStore.invalidUsageError)
256+
if (workStore.invalidDynamicUsageError) {
257+
controller.error(workStore.invalidDynamicUsageError)
258258
} else if (idx < buffer.length) {
259259
controller.enqueue(buffer[idx++])
260260
} else if (errors.length > 0) {
@@ -365,7 +365,6 @@ async function generateCacheEntryImpl(
365365
const resultPromise = createLazyResult(() => fn.apply(null, args))
366366

367367
let errors: Array<unknown> = []
368-
let timeoutErrorHandled = false
369368

370369
// In the "Cache" environment, we only need to make sure that the error
371370
// digests are handled correctly. Error formatting and reporting is not
@@ -385,13 +384,6 @@ async function generateCacheEntryImpl(
385384
console.error(error)
386385
}
387386

388-
if (error === timeoutError) {
389-
timeoutErrorHandled = true
390-
// The timeout error already aborted the whole stream. We don't need
391-
// to also push this error into the `errors` array.
392-
return timeoutError.digest
393-
}
394-
395387
errors.push(error)
396388
}
397389

@@ -404,6 +396,7 @@ async function generateCacheEntryImpl(
404396
// Otherwise we assume you stalled on hanging input and de-opt. This needs
405397
// to be lower than just the general timeout of 60 seconds.
406398
const timer = setTimeout(() => {
399+
workStore.invalidDynamicUsageError = timeoutError
407400
timeoutAbortController.abort(timeoutError)
408401
}, 50000)
409402

@@ -422,7 +415,7 @@ async function generateCacheEntryImpl(
422415
signal: abortSignal,
423416
temporaryReferences,
424417
onError(error) {
425-
if (renderSignal.aborted && renderSignal.reason === error) {
418+
if (abortSignal.aborted && abortSignal.reason === error) {
426419
return undefined
427420
}
428421

@@ -433,11 +426,7 @@ async function generateCacheEntryImpl(
433426

434427
clearTimeout(timer)
435428

436-
if (timeoutAbortController.signal.aborted && !timeoutErrorHandled) {
437-
// When halting is enabled, the prerender will not call `onError` when
438-
// it's aborted with the timeout abort signal, and hanging promises will
439-
// also not be rejected. In this case, we're creating an erroring stream
440-
// here, to ensure that the error is propagated to the server environment.
429+
if (timeoutAbortController.signal.aborted) {
441430
stream = new ReadableStream({
442431
start(controller) {
443432
controller.error(timeoutError)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use cache'
2+
3+
export default async function Page({
4+
params,
5+
}: {
6+
params: Promise<{ slug: string }>
7+
}) {
8+
const { slug } = await params
9+
10+
return <p>slug: {slug}</p>
11+
}
12+
13+
// If generateStaticParams would be used here to define at least one set of
14+
// complete params, we would not yield a timeout error.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
async function getSearchParam({
2+
searchParams,
3+
}: {
4+
searchParams: Promise<{ n: string }>
5+
}): Promise<string> {
6+
'use cache'
7+
8+
return (await searchParams).n
9+
}
10+
11+
export default async function Page({
12+
searchParams,
13+
}: {
14+
searchParams: Promise<{ n: string }>
15+
}) {
16+
let searchParam: string | undefined
17+
18+
try {
19+
searchParam = await getSearchParam({ searchParams })
20+
} catch {
21+
// Ignore not having access to searchParams. This is still an invalid
22+
// dynamic access though that we need to detect.
23+
}
24+
25+
return (
26+
<p>{searchParam ? `search param: ${searchParam}` : 'no search param'}</p>
27+
)
28+
}

0 commit comments

Comments
 (0)