From d1ee0f0cd7568318fa2cb5c23356f1a01fcd21db Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 10 May 2025 10:37:28 +0200 Subject: [PATCH] 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. --- .../next/src/server/app-render/app-render.tsx | 58 +++-- .../server/app-render/dynamic-rendering.ts | 11 +- .../app-render/work-async-storage.external.ts | 7 +- packages/next/src/server/request/utils.ts | 2 +- .../src/server/use-cache/use-cache-wrapper.ts | 21 +- .../app/fallback-params/[slug]/page.tsx | 14 ++ .../app/search-params-caught/page.tsx | 28 +++ .../app/uncached-promise-nested/page.tsx | 2 +- .../app/uncached-promise/page.tsx | 2 +- .../use-cache-hanging-inputs.test.ts | 218 ++++++++++++------ 10 files changed, 238 insertions(+), 125 deletions(-) create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-caught/page.tsx diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 4e468248394b..9efa44f585dc 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -187,7 +187,6 @@ import { } from '../resume-data-cache/resume-data-cache' import type { MetadataErrorType } from '../../lib/metadata/resolve-metadata' import isError from '../../lib/is-error' -import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors' import { createServerInsertedMetadata } from './metadata-insertion/create-server-inserted-metadata' import { getPreviouslyRevalidatedTags } from '../server-utils' import { executeRevalidates } from '../revalidation-utils' @@ -632,7 +631,6 @@ async function generateDynamicFlightRenderResult( ctx, false, ctx.clientReferenceManifest, - ctx.workStore.route, requestStore ) } @@ -1390,7 +1388,6 @@ async function renderToHTMLOrFlightImpl( res, ctx, metadata, - workStore, loaderTree ) @@ -1410,8 +1407,8 @@ async function renderToHTMLOrFlightImpl( // If we encountered any unexpected errors during build we fail the // prerendering phase and the build. - if (workStore.invalidUsageError) { - throw workStore.invalidUsageError + if (workStore.invalidDynamicUsageError) { + throw workStore.invalidDynamicUsageError } if (response.digestErrorsMap.size) { const buildFailingError = response.digestErrorsMap.values().next().value @@ -1564,7 +1561,6 @@ async function renderToHTMLOrFlightImpl( req, res, ctx, - workStore, notFoundLoaderTree, formState, postponedState @@ -1591,14 +1587,13 @@ async function renderToHTMLOrFlightImpl( req, res, ctx, - workStore, loaderTree, formState, postponedState ) - if (workStore.invalidUsageError) { - throw workStore.invalidUsageError + if (workStore.invalidDynamicUsageError) { + throw workStore.invalidDynamicUsageError } // If we have pending revalidates, wait until they are all resolved. @@ -1727,7 +1722,6 @@ async function renderToStream( req: BaseNextRequest, res: BaseNextResponse, ctx: AppRenderContext, - workStore: WorkStore, tree: LoaderTree, formState: any, postponedState: PostponedState | null @@ -1873,7 +1867,6 @@ async function renderToStream( ctx, res.statusCode === 404, clientReferenceManifest, - workStore.route, requestStore ) @@ -2220,10 +2213,9 @@ async function spawnDynamicValidationInDev( ctx: AppRenderContext, isNotFound: boolean, clientReferenceManifest: NonNullable, - route: string, requestStore: RequestStore ): Promise { - const { componentMod: ComponentMod, implicitTags } = ctx + const { componentMod: ComponentMod, implicitTags, workStore } = ctx const rootParams = getRootParams( ComponentMod.tree, ctx.getDynamicParamFromSegment @@ -2317,7 +2309,7 @@ async function spawnDynamicValidationInDev( process.env.NEXT_DEBUG_BUILD || process.env.__NEXT_VERBOSE_LOGGING ) { - printDebugThrownValueForProspectiveRender(err, route) + printDebugThrownValueForProspectiveRender(err, workStore.route) } }, signal: initialServerRenderController.signal, @@ -2335,7 +2327,7 @@ async function spawnDynamicValidationInDev( ) { // We don't normally log these errors because we are going to retry anyway but // it can be useful for debugging Next.js itself to get visibility here when needed - printDebugThrownValueForProspectiveRender(err, route) + printDebugThrownValueForProspectiveRender(err, workStore.route) } } @@ -2375,7 +2367,7 @@ async function spawnDynamicValidationInDev( ) { // We don't normally log these errors because we are going to retry anyway but // it can be useful for debugging Next.js itself to get visibility here when needed - printDebugThrownValueForProspectiveRender(err, route) + printDebugThrownValueForProspectiveRender(err, workStore.route) } }, } @@ -2387,7 +2379,7 @@ async function spawnDynamicValidationInDev( // We're going to retry to so we normally would suppress this error but // when verbose logging is on we print it if (process.env.__NEXT_VERBOSE_LOGGING) { - printDebugThrownValueForProspectiveRender(err, route) + printDebugThrownValueForProspectiveRender(err, workStore.route) } } }) @@ -2467,10 +2459,6 @@ async function spawnDynamicValidationInDev( clientReferenceManifest.clientModules, { onError: (err) => { - if (isUseCacheTimeoutError(err)) { - return err.digest - } - if ( finalServerController.signal.aborted && isPrerenderInterruptedError(err) @@ -2511,12 +2499,6 @@ async function spawnDynamicValidationInDev( { signal: finalClientController.signal, onError: (err, errorInfo) => { - if (isUseCacheTimeoutError(err)) { - dynamicValidation.dynamicErrors.push(err) - - return - } - if ( isPrerenderInterruptedError(err) || finalClientController.signal.aborted @@ -2531,7 +2513,7 @@ async function spawnDynamicValidationInDev( const componentStack = errorInfo.componentStack if (typeof componentStack === 'string') { trackAllowedDynamicAccess( - route, + workStore.route, componentStack, dynamicValidation ) @@ -2572,7 +2554,7 @@ async function spawnDynamicValidationInDev( // track any dynamic access that occurs above the suspense boundary because // we'll do so in the route shell. throwIfDisallowedDynamic( - route, + workStore, preludeIsEmpty, dynamicValidation, serverDynamicTracking, @@ -2611,7 +2593,6 @@ async function prerenderToStream( res: BaseNextResponse, ctx: AppRenderContext, metadata: AppPageRenderResultMetadata, - workStore: WorkStore, tree: LoaderTree ): Promise { // When prerendering formState is always null. We still include it @@ -2626,6 +2607,7 @@ async function prerenderToStream( nonce, pagePath, renderOpts, + workStore, } = ctx const rootParams = getRootParams(tree, getDynamicParamFromSegment) @@ -2840,6 +2822,12 @@ async function prerenderToStream( initialServerRenderController.abort() initialServerPrerenderController.abort() + // We don't need to continue the prerender process if we already + // detected invalid dynamic usage in the initial prerender phase. + if (workStore.invalidDynamicUsageError) { + throw workStore.invalidDynamicUsageError + } + let initialServerResult try { initialServerResult = await createReactServerPrerenderResult( @@ -3106,7 +3094,7 @@ async function prerenderToStream( // we'll do so in the route shell. if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) { throwIfDisallowedDynamic( - workStore.route, + workStore, preludeIsEmpty, dynamicValidation, serverDynamicTracking, @@ -3428,6 +3416,12 @@ async function prerenderToStream( initialServerRenderController.abort() initialServerPrerenderController.abort() + // We don't need to continue the prerender process if we already + // detected invalid dynamic usage in the initial prerender phase. + if (workStore.invalidDynamicUsageError) { + throw workStore.invalidDynamicUsageError + } + // We've now filled caches and triggered any inadvertant sync bailouts // due to lazy module initialization. We can restart our render to capture results @@ -3591,7 +3585,7 @@ async function prerenderToStream( if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) { // We don't have a shell because the root errored when we aborted. throwIfDisallowedDynamic( - workStore.route, + workStore, preludeIsEmpty, dynamicValidation, serverDynamicTracking, diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 82600a447d1a..0d5cd67cf00e 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -651,12 +651,17 @@ function createErrorWithComponentStack( } export function throwIfDisallowedDynamic( - route: string, + workStore: WorkStore, hasEmptyShell: boolean, dynamicValidation: DynamicValidationState, serverDynamic: DynamicTrackingState, clientDynamic: DynamicTrackingState ): void { + if (workStore.invalidDynamicUsageError) { + console.error(workStore.invalidDynamicUsageError) + throw new StaticGenBailoutError() + } + if (hasEmptyShell) { if (dynamicValidation.hasSuspenseAboveBody) { // This route has opted into allowing fully dynamic rendering @@ -698,7 +703,7 @@ export function throwIfDisallowedDynamic( // to indicate your are ok with fully dynamic rendering. if (dynamicValidation.hasDynamicViewport) { console.error( - `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` + `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` ) throw new StaticGenBailoutError() } @@ -708,7 +713,7 @@ export function throwIfDisallowedDynamic( dynamicValidation.hasDynamicMetadata ) { console.error( - `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` + `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` ) throw new StaticGenBailoutError() } diff --git a/packages/next/src/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index 5335c999f5c7..7e7380e39e8d 100644 --- a/packages/next/src/server/app-render/work-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-async-storage.external.ts @@ -51,12 +51,13 @@ export interface WorkStore { dynamicUsageStack?: string /** - * Invalid usage errors might be caught in userland. We attach them to the - * work store to ensure we can still fail the build or dev render. + * Invalid dynamic usage errors might be caught in userland. We attach them to + * the work store to ensure we can still fail the build, or show en error in + * dev mode. */ // TODO: Collect an array of errors, and throw as AggregateError when // `serializeError` and the Dev Overlay support it. - invalidUsageError?: Error + invalidDynamicUsageError?: Error nextFetchId?: number pathWasRevalidated?: boolean diff --git a/packages/next/src/server/request/utils.ts b/packages/next/src/server/request/utils.ts index e6d007b2605b..4c9b2b6a3c22 100644 --- a/packages/next/src/server/request/utils.ts +++ b/packages/next/src/server/request/utils.ts @@ -29,7 +29,7 @@ export function throwForSearchParamsAccessInUseCache( ) Error.captureStackTrace(error, constructorOpt) - workStore.invalidUsageError ??= error + workStore.invalidDynamicUsageError ??= error throw error } diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 9738f5078e75..6f7c21d51954 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -253,8 +253,8 @@ async function collectResult( let idx = 0 const bufferStream = new ReadableStream({ pull(controller) { - if (workStore.invalidUsageError) { - controller.error(workStore.invalidUsageError) + if (workStore.invalidDynamicUsageError) { + controller.error(workStore.invalidDynamicUsageError) } else if (idx < buffer.length) { controller.enqueue(buffer[idx++]) } else if (errors.length > 0) { @@ -365,7 +365,6 @@ async function generateCacheEntryImpl( const resultPromise = createLazyResult(() => fn.apply(null, args)) let errors: Array = [] - let timeoutErrorHandled = false // In the "Cache" environment, we only need to make sure that the error // digests are handled correctly. Error formatting and reporting is not @@ -385,13 +384,6 @@ async function generateCacheEntryImpl( console.error(error) } - if (error === timeoutError) { - timeoutErrorHandled = true - // The timeout error already aborted the whole stream. We don't need - // to also push this error into the `errors` array. - return timeoutError.digest - } - errors.push(error) } @@ -404,6 +396,7 @@ async function generateCacheEntryImpl( // Otherwise we assume you stalled on hanging input and de-opt. This needs // to be lower than just the general timeout of 60 seconds. const timer = setTimeout(() => { + workStore.invalidDynamicUsageError = timeoutError timeoutAbortController.abort(timeoutError) }, 50000) @@ -422,7 +415,7 @@ async function generateCacheEntryImpl( signal: abortSignal, temporaryReferences, onError(error) { - if (renderSignal.aborted && renderSignal.reason === error) { + if (abortSignal.aborted && abortSignal.reason === error) { return undefined } @@ -433,11 +426,7 @@ async function generateCacheEntryImpl( clearTimeout(timer) - if (timeoutAbortController.signal.aborted && !timeoutErrorHandled) { - // When halting is enabled, the prerender will not call `onError` when - // it's aborted with the timeout abort signal, and hanging promises will - // also not be rejected. In this case, we're creating an erroring stream - // here, to ensure that the error is propagated to the server environment. + if (timeoutAbortController.signal.aborted) { stream = new ReadableStream({ start(controller) { controller.error(timeoutError) diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx new file mode 100644 index 000000000000..1f77b26ac270 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx @@ -0,0 +1,14 @@ +'use cache' + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + return

slug: {slug}

+} + +// If generateStaticParams would be used here to define at least one set of +// complete params, we would not yield a timeout error. diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-caught/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-caught/page.tsx new file mode 100644 index 000000000000..4eac0fe1a414 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-caught/page.tsx @@ -0,0 +1,28 @@ +async function getSearchParam({ + searchParams, +}: { + searchParams: Promise<{ n: string }> +}): Promise { + 'use cache' + + return (await searchParams).n +} + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ n: string }> +}) { + let searchParam: string | undefined + + try { + searchParam = await getSearchParam({ searchParams }) + } catch { + // Ignore not having access to searchParams. This is still an invalid + // dynamic access though that we need to detect. + } + + return ( +

{searchParam ? `search param: ${searchParam}` : 'no search param'}

+ ) +} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx index 5746ea3d55f0..f97f8158ec16 100644 --- a/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx @@ -2,7 +2,7 @@ import React from 'react' import { setTimeout } from 'timers/promises' async function getUncachedData() { - await setTimeout(0) + await setTimeout(100) return Math.random() } diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx index 7a18769bd24a..da6ee6f8ebda 100644 --- a/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx @@ -2,7 +2,7 @@ import React from 'react' import { setTimeout } from 'timers/promises' async function fetchUncachedData() { - await setTimeout(0) + await setTimeout(100) return Math.random() } diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts index e6b2a30b37fb..196219534525 100644 --- a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts +++ b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts @@ -10,9 +10,7 @@ import { } from 'next-test-utils' import stripAnsi from 'strip-ansi' -const isExperimentalReact = process.env.__NEXT_EXPERIMENTAL_PPR - -const expectedErrorMessage = +const expectedTimeoutErrorMessage = 'Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".' describe('use-cache-hanging-inputs', () => { @@ -38,21 +36,29 @@ describe('use-cache-hanging-inputs', () => { await openRedbox(browser) + const errorCount = await getRedboxTotalErrorCount(browser) const errorDescription = await getRedboxDescription(browser) const errorSource = await getRedboxSource(browser) - expect(errorDescription).toBe(expectedErrorMessage) + expect(errorCount).toBe(1) + expect(errorDescription).toBe(expectedTimeoutErrorMessage) const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) if (isTurbopack) { - // TODO(veil): For Turbopack, a fix in the React Flight Client, where - // sourceURL is encoded, is needed for the error source and stack - // frames to be source mapped. + expect(errorSource).toMatchInlineSnapshot(` + "app/search-params/page.tsx (3:16) @ [project]/app/search-params/page.tsx [app-rsc] (ecmascript) - expect(errorSource).toBe(null) + 1 | 'use cache' + 2 | + > 3 | export default async function Page({ + | ^ + 4 | searchParams, + 5 | }: { + 6 | searchParams: Promise<{ n: string }>" + `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at [project]/app/search-params/page.tsx [app-rsc] (ecmascript)`) } else { expect(errorSource).toMatchInlineSnapshot(` @@ -67,12 +73,62 @@ describe('use-cache-hanging-inputs', () => { 6 | searchParams: Promise<{ n: string }>" `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at eval (app/search-params/page.tsx:3:15)`) } }, 180_000) }) + describe('when searchParams are used inside of "use cache", wrapped in try/catch', () => { + it('should show an error toast after a timeout', async () => { + const outputIndex = next.cliOutput.length + const browser = await next.browser('/search-params-caught?n=1') + + // The request is pending while we stall on the hanging inputs, and + // playwright will wait for the load event before continuing. So we + // don't need to wait for the "use cache" timeout of 50 seconds here. + + await openRedbox(browser) + + const errorCount = await getRedboxTotalErrorCount(browser) + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorCount).toBe(1) + expect(errorDescription).toBe(expectedTimeoutErrorMessage) + + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + + if (isTurbopack) { + expect(errorSource).toMatchInlineSnapshot(` + "app/search-params-caught/page.tsx (1:1) @ [project]/app/search-params-caught/page.tsx [app-rsc] (ecmascript) + + > 1 | async function getSearchParam({ + | ^ + 2 | searchParams, + 3 | }: { + 4 | searchParams: Promise<{ n: string }>" + `) + + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} + at [project]/app/search-params-caught/page.tsx [app-rsc] (ecmascript)`) + } else { + expect(errorSource).toMatchInlineSnapshot(` + "app/search-params-caught/page.tsx (1:1) @ eval + + > 1 | async function getSearchParam({ + | ^ + 2 | searchParams, + 3 | }: { + 4 | searchParams: Promise<{ n: string }>" + `) + + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} + at eval (app/search-params-caught/page.tsx:1:0)`) + } + }, 180_000) + }) + describe('when searchParams are unused inside of "use cache"', () => { it('should not show an error', async () => { const outputIndex = next.cliOutput.length @@ -82,7 +138,7 @@ describe('use-cache-hanging-inputs', () => { const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) - expect(cliOutput).not.toContain(`Error: ${expectedErrorMessage}`) + expect(cliOutput).not.toContain(`Error: ${expectedTimeoutErrorMessage}`) }) }) @@ -97,21 +153,29 @@ describe('use-cache-hanging-inputs', () => { await openRedbox(browser) + const errorCount = await getRedboxTotalErrorCount(browser) const errorDescription = await getRedboxDescription(browser) const errorSource = await getRedboxSource(browser) - expect(errorDescription).toBe(expectedErrorMessage) + expect(errorCount).toBe(1) + expect(errorDescription).toBe(expectedTimeoutErrorMessage) const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) if (isTurbopack) { - // TODO(veil): For Turbopack, a fix in the React Flight Client, where - // sourceURL is encoded, is needed for the error source and stack - // frames to be source mapped. + expect(errorSource).toMatchInlineSnapshot(` + "app/uncached-promise/page.tsx (10:13) @ [project]/app/uncached-promise/page.tsx [app-rsc] (ecmascript) - expect(errorSource).toBe(null) + 8 | } + 9 | + > 10 | const Foo = async ({ promise }) => { + | ^ + 11 | 'use cache' + 12 | + 13 | return (" + `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at [project]/app/uncached-promise/page.tsx [app-rsc] (ecmascript)`) } else { expect(errorSource).toMatchInlineSnapshot(` @@ -126,7 +190,7 @@ describe('use-cache-hanging-inputs', () => { 13 | return (" `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at eval (app/uncached-promise/page.tsx:10:12)`) } }, 180_000) @@ -143,21 +207,29 @@ describe('use-cache-hanging-inputs', () => { await openRedbox(browser) + const errorCount = await getRedboxTotalErrorCount(browser) const errorDescription = await getRedboxDescription(browser) const errorSource = await getRedboxSource(browser) - expect(errorDescription).toBe(expectedErrorMessage) + expect(errorCount).toBe(1) + expect(errorDescription).toBe(expectedTimeoutErrorMessage) const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) if (isTurbopack) { - // TODO(veil): For Turbopack, a fix in the React Flight Client, where - // sourceURL is encoded, is needed for the error source and stack - // frames to be source mapped. + expect(errorSource).toMatchInlineSnapshot(` + "app/uncached-promise-nested/page.tsx (16:1) @ [project]/app/uncached-promise-nested/page.tsx [app-rsc] (ecmascript) - expect(errorSource).toBe(null) + 14 | } + 15 | + > 16 | async function indirection(promise: Promise) { + | ^ + 17 | 'use cache' + 18 | + 19 | return getCachedData(promise)" + `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at [project]/app/uncached-promise-nested/page.tsx [app-rsc] (ecmascript)`) } else { expect(errorSource).toMatchInlineSnapshot(` @@ -172,7 +244,7 @@ describe('use-cache-hanging-inputs', () => { 19 | return getCachedData(promise)" `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at eval (app/uncached-promise-nested/page.tsx:16:0)`) } }, 180_000) @@ -189,54 +261,46 @@ describe('use-cache-hanging-inputs', () => { await openRedbox(browser) + const errorCount = await getRedboxTotalErrorCount(browser) const errorDescription = await getRedboxDescription(browser) const errorSource = await getRedboxSource(browser) - const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + expect(errorCount).toBe(1) - if (isExperimentalReact) { - // TODO(react-time-info): Remove this branch for experimental React when the issue is - // resolved where the inclusion of server timings in the RSC payload - // makes the serialized bound args not suitable to be used as a cache - // key. + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) - const expectedErrorMessagePpr = - 'Route "/bound-args": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. We don\'t have the exact line number added to error messages yet but you can see which component in the stack below. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense' + expect(errorDescription).toBe(expectedTimeoutErrorMessage) - expect(errorDescription).toBe(expectedErrorMessagePpr) + if (isTurbopack) { + expect(errorSource).toMatchInlineSnapshot(` + "app/bound-args/page.tsx (13:15) @ [project]/app/bound-args/page.tsx [app-rsc] (ecmascript) + + 11 | const uncachedDataPromise = fetchUncachedData() + 12 | + > 13 | const Foo = async () => { + | ^ + 14 | 'use cache' + 15 | + 16 | return (" + `) - expect(cliOutput).toContain( - `${expectedErrorMessagePpr} - at Page [Server] ()` - ) + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} + at [project]/app/bound-args/page.tsx [app-rsc] (ecmascript)`) } else { - expect(errorDescription).toBe(expectedErrorMessage) - - if (isTurbopack) { - // TODO(veil): For Turbopack, a fix in the React Flight Client, where - // sourceURL is encoded, is needed for the error source and stack - // frames to be source mapped. - - expect(errorSource).toBe(null) + expect(errorSource).toMatchInlineSnapshot(` + "app/bound-args/page.tsx (13:15) @ eval + + 11 | const uncachedDataPromise = fetchUncachedData() + 12 | + > 13 | const Foo = async () => { + | ^ + 14 | 'use cache' + 15 | + 16 | return (" + `) - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} - at [project]/app/bound-args/page.tsx [app-rsc] (ecmascript)`) - } else { - expect(errorSource).toMatchInlineSnapshot(` - "app/bound-args/page.tsx (13:15) @ eval - - 11 | const uncachedDataPromise = fetchUncachedData() - 12 | - > 13 | const Foo = async () => { - | ^ - 14 | 'use cache' - 15 | - 16 | return (" - `) - - expect(cliOutput).toContain(`Error: ${expectedErrorMessage} + expect(cliOutput).toContain(`Error: ${expectedTimeoutErrorMessage} at eval (app/bound-args/page.tsx:13:14)`) - } } }, 180_000) }) @@ -262,23 +326,41 @@ describe('use-cache-hanging-inputs', () => { it('should fail the build with errors after a timeout', async () => { const { cliOutput } = await next.build() - expect(cliOutput).toInclude(`Error: ${expectedErrorMessage}`) + expect(cliOutput).toInclude( + createExpectedBuildErrorMessage('/error', 'kaputt!') + ) + + expect(cliOutput).toInclude( + createExpectedBuildErrorMessage('/bound-args') + ) expect(cliOutput).toInclude( - 'Error occurred prerendering page "/bound-args"' + createExpectedBuildErrorMessage('/fallback-params/[slug]') ) expect(cliOutput).toInclude( - 'Error occurred prerendering page "/search-params"' + createExpectedBuildErrorMessage('/search-params') ) expect(cliOutput).toInclude( - 'Error occurred prerendering page "/uncached-promise"' + createExpectedBuildErrorMessage('/search-params-caught') ) expect(cliOutput).toInclude( - 'Error occurred prerendering page "/uncached-promise-nested"' + createExpectedBuildErrorMessage('/uncached-promise') + ) + + expect(cliOutput).toInclude( + createExpectedBuildErrorMessage('/uncached-promise-nested') ) }, 180_000) } }) + +function createExpectedBuildErrorMessage( + pathname: string, + errorMessage: string = expectedTimeoutErrorMessage +) { + return `Error occurred prerendering page "${pathname}". Read more: https://nextjs.org/docs/messages/prerender-error +Error: ${errorMessage}` +}