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}` +}