From 338eb99553d48ce78d6d02b98113a16e70950cc9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 22 Jan 2025 22:39:18 +0100 Subject: [PATCH] Restore and enhance error handling for hanging inputs in `"use cache"` (#74652) Uncached and forever hanging promises are currently not supported as input for `"use cache"` functions. One prominent example of this is the use of `searchParams` in a page with `"use cache"`. Until Next.js version 15.1.1, the usage of such inputs led to an error during build after a timeout of 50 seconds. This behaviour relied on a bug in `decodeReply` though, and was hence broken after React fixed the bug. A build will now fail early with `Error: Connection closed.`. With this PR, we are restoring the timeout error behaviour by switching to the new `decodeReplyFromAsyncIterable` API, which allows us to keep an open stream when decoding the cached function's arguments. In addition, in dev mode, we are now also propagating the error to the dev overlay. --- .../next/src/server/app-render/app-render.tsx | 22 ++- .../src/server/lib/cache-handlers/default.ts | 3 +- .../src/server/use-cache/use-cache-errors.ts | 26 +++ .../src/server/use-cache/use-cache-wrapper.ts | 118 +++++++----- packages/next/types/$$compiled.internal.d.ts | 7 + .../app/error/page.tsx | 7 + .../use-cache-hanging-inputs/app/layout.tsx | 8 + .../app/search-params-unused/page.tsx | 9 + .../app/search-params/page.tsx | 11 ++ .../app/uncached-promise-nested/page.tsx | 26 +++ .../app/uncached-promise/page.tsx | 23 +++ .../use-cache-hanging-inputs/next.config.js | 11 ++ .../use-cache-hanging-inputs.test.ts | 173 ++++++++++++++++++ 13 files changed, 396 insertions(+), 48 deletions(-) create mode 100644 packages/next/src/server/use-cache/use-cache-errors.ts create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/error/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/layout.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-unused/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/search-params/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/next.config.js create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index e32adafeeb95c..392ecacb048c0 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -184,6 +184,7 @@ 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' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -610,7 +611,7 @@ async function generateDynamicFlightRenderResult( ctx.clientReferenceManifest, ctx.workStore.route, requestStore - ).catch(resolveValidation) // avoid unhandled rejections and a forever hanging promise + ) } // For app dir, use the bundled version of Flight server renderer (renderToReadableStream) @@ -1796,7 +1797,7 @@ async function renderToStream( clientReferenceManifest, workStore.route, requestStore - ).catch(resolveValidation) // avoid unhandled rejections and a forever hanging promise + ) reactServerResult = new ReactServerResult(reactServerStream) } else { @@ -2356,6 +2357,10 @@ async function spawnDynamicValidationInDev( clientReferenceManifest.clientModules, { onError: (err) => { + if (isUseCacheTimeoutError(err)) { + return err.digest + } + if ( finalServerController.signal.aborted && isPrerenderInterruptedError(err) @@ -2392,6 +2397,12 @@ async function spawnDynamicValidationInDev( { signal: finalClientController.signal, onError: (err, errorInfo) => { + if (isUseCacheTimeoutError(err)) { + dynamicValidation.dynamicErrors.push(err) + + return + } + if ( isPrerenderInterruptedError(err) || finalClientController.signal.aborted @@ -2427,8 +2438,11 @@ async function spawnDynamicValidationInDev( ) { // we don't have a root because the abort errored in the root. We can just ignore this error } else { - // This error is something else and should bubble up - throw err + // If an error is thrown in the root before prerendering is aborted, we + // don't want to rethrow it here, otherwise this would lead to a hanging + // response and unhandled rejection. We also don't want to log it, because + // it's most likely already logged as part of the normal render. So we + // just fall through here, to make sure `resolveValidation` is called. } } diff --git a/packages/next/src/server/lib/cache-handlers/default.ts b/packages/next/src/server/lib/cache-handlers/default.ts index 5c8f6c3cf8065..3179de20bccac 100644 --- a/packages/next/src/server/lib/cache-handlers/default.ts +++ b/packages/next/src/server/lib/cache-handlers/default.ts @@ -94,8 +94,7 @@ const DefaultCacheHandler: CacheHandler = { errorRetryCount: 0, size, }) - } catch (err) { - console.error(`Error while saving cache key: ${cacheKey}`, err) + } catch { // TODO: store partial buffer with error after we retry 3 times } finally { resolvePending() diff --git a/packages/next/src/server/use-cache/use-cache-errors.ts b/packages/next/src/server/use-cache/use-cache-errors.ts new file mode 100644 index 0000000000000..b7c1e907d8c3e --- /dev/null +++ b/packages/next/src/server/use-cache/use-cache-errors.ts @@ -0,0 +1,26 @@ +const USE_CACHE_TIMEOUT_ERROR_CODE = 'USE_CACHE_TIMEOUT' + +export class UseCacheTimeoutError extends Error { + digest: typeof USE_CACHE_TIMEOUT_ERROR_CODE = USE_CACHE_TIMEOUT_ERROR_CODE + + constructor() { + super( + '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".' + ) + } +} + +export function isUseCacheTimeoutError( + err: unknown +): err is UseCacheTimeoutError { + if ( + typeof err !== 'object' || + err === null || + !('digest' in err) || + typeof err.digest !== 'string' + ) { + return false + } + + return err.digest === USE_CACHE_TIMEOUT_ERROR_CODE +} 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 6768e222a08d2..7b84de7102c33 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -3,6 +3,7 @@ import type { DeepReadonly } from '../../shared/lib/deep-readonly' import { renderToReadableStream, decodeReply, + decodeReplyFromAsyncIterable, createTemporaryReferenceSet as createServerTemporaryReferenceSet, } from 'react-server-dom-webpack/server.edge' /* eslint-disable import/no-extraneous-dependencies */ @@ -39,6 +40,7 @@ import { decryptActionBoundArgs } from '../app-render/encryption' import { InvariantError } from '../../shared/lib/invariant-error' import { getDigestForWellKnownError } from '../app-render/create-error-handler' import { cacheHandlerGlobal, DYNAMIC_EXPIRE } from './constants' +import { UseCacheTimeoutError } from './use-cache-errors' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -49,7 +51,8 @@ function generateCacheEntry( outerWorkUnitStore: WorkUnitStore | undefined, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, - fn: any + fn: any, + timeoutError: UseCacheTimeoutError ): Promise<[ReadableStream, Promise]> { // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache // generation cannot read anything from the context we're currently executing which @@ -62,7 +65,8 @@ function generateCacheEntry( outerWorkUnitStore, clientReferenceManifest, encodedArguments, - fn + fn, + timeoutError ) } @@ -71,7 +75,8 @@ function generateCacheEntryWithRestoredWorkStore( outerWorkUnitStore: WorkUnitStore | undefined, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, - fn: any + fn: any, + timeoutError: UseCacheTimeoutError ) { // Since we cleared the AsyncLocalStorage we need to restore the workStore. // Note: We explicitly don't restore the RequestStore nor the PrerenderStore. @@ -87,7 +92,8 @@ function generateCacheEntryWithRestoredWorkStore( outerWorkUnitStore, clientReferenceManifest, encodedArguments, - fn + fn, + timeoutError ) } @@ -96,7 +102,8 @@ function generateCacheEntryWithCacheContext( outerWorkUnitStore: WorkUnitStore | undefined, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, - fn: any + fn: any, + timeoutError: UseCacheTimeoutError ) { if (!workStore.cacheLifeProfiles) { throw new Error( @@ -135,12 +142,12 @@ function generateCacheEntryWithCacheContext( return workUnitAsyncStorage.run( cacheStore, generateCacheEntryImpl, - workStore, outerWorkUnitStore, cacheStore, clientReferenceManifest, encodedArguments, - fn + fn, + timeoutError ) } @@ -261,22 +268,49 @@ async function collectResult( } async function generateCacheEntryImpl( - workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, innerCacheStore: UseCacheStore, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, - fn: any + fn: any, + timeoutError: UseCacheTimeoutError ): Promise<[ReadableStream, Promise]> { const temporaryReferences = createServerTemporaryReferenceSet() - const [, , args] = await decodeReply( - encodedArguments, - getServerModuleMap(), - { - temporaryReferences, - } - ) + const [, , args] = + typeof encodedArguments === 'string' + ? await decodeReply(encodedArguments, getServerModuleMap(), { + temporaryReferences, + }) + : await decodeReplyFromAsyncIterable( + { + async *[Symbol.asyncIterator]() { + for (const entry of encodedArguments) { + yield entry + } + + // The encoded arguments might contain hanging promises. In this + // case we don't want to reject with "Error: Connection closed.", + // so we intentionally keep the iterable alive. This is similar to + // the halting trick that we do while rendering. + if (outerWorkUnitStore?.type === 'prerender') { + await new Promise((resolve) => { + if (outerWorkUnitStore.renderSignal.aborted) { + resolve() + } else { + outerWorkUnitStore.renderSignal.addEventListener( + 'abort', + () => resolve(), + { once: true } + ) + } + }) + } + }, + }, + getServerModuleMap(), + { temporaryReferences } + ) // Track the timestamp when we started copmuting the result. const startTime = performance.timeOrigin + performance.now() @@ -287,17 +321,12 @@ async function generateCacheEntryImpl( let timer = undefined const controller = new AbortController() - if (workStore.isStaticGeneration) { + if (outerWorkUnitStore?.type === 'prerender') { // If we're prerendering, we give you 50 seconds to fill a cache entry. Otherwise // we assume you stalled on hanging input and deopt. This needs to be lower than // just the general timeout of 60 seconds. timer = setTimeout(() => { - controller.abort( - new Error( - '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".' - ) - ) + controller.abort(timeoutError) }, 50000) } @@ -319,10 +348,19 @@ async function generateCacheEntryImpl( return digest } - // TODO: For now we're also reporting the error here, because in - // production, the "Server" environment will only get the obfuscated - // error (created by the Flight Client in the cache wrapper). - console.error(error) + if (process.env.NODE_ENV !== 'development') { + // TODO: For now we're also reporting the error here, because in + // production, the "Server" environment will only get the obfuscated + // error (created by the Flight Client in the cache wrapper). + console.error(error) + } + + if (error === timeoutError) { + // 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) }, } @@ -441,6 +479,11 @@ export function cache( if (cacheHandler === undefined) { throw new Error('Unknown cache handler: ' + kind) } + + // Capture the timeout error here to ensure a useful stack. + const timeoutError = new UseCacheTimeoutError() + Error.captureStackTrace(timeoutError, cache) + const name = fn.name const cachedFn = { [name]: async function (...args: any[]) { @@ -463,7 +506,7 @@ export function cache( // the implementation. const buildId = workStore.buildId - let abortHangingInputSignal: null | AbortSignal = null + let abortHangingInputSignal: undefined | AbortSignal if (workUnitStore && workUnitStore.type === 'prerender') { // In a prerender, we may end up with hanging Promises as inputs due them stalling // on connection() or because they're loading dynamic data. In that case we need to @@ -515,18 +558,7 @@ export function cache( const temporaryReferences = createClientTemporaryReferenceSet() const encodedArguments: FormData | string = await encodeReply( [buildId, id, args], - // Right now this is enough to cause the input to generate hanging Promises - // but that's really due to what is probably a React bug in decodeReply. - // If that's fixed we may need a different strategy. We can also just skip - // the serialization/cache in this scenario and pass-through raw objects. - abortHangingInputSignal - ? { - temporaryReferences, - signal: abortHangingInputSignal, - } - : { - temporaryReferences, - } + { temporaryReferences, signal: abortHangingInputSignal } ) const serializedCacheKey = @@ -656,7 +688,8 @@ export function cache( workUnitStore, clientReferenceManifest, encodedArguments, - fn + fn, + timeoutError ) let savedCacheEntry @@ -715,7 +748,8 @@ export function cache( undefined, // This is not running within the context of this unit. clientReferenceManifest, encodedArguments, - fn + fn, + timeoutError ) let savedCacheEntry: Promise diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 7958e547bc936..dd565e4bbdac0 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -140,6 +140,13 @@ declare module 'react-server-dom-webpack/server.edge' { temporaryReferences?: TemporaryReferenceSet } ): Promise + export function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + webpackMap: ServerManifest, + options?: { + temporaryReferences?: TemporaryReferenceSet + } + ): Promise export function decodeAction( body: FormData, serverManifest: ServerManifest diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/error/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/error/page.tsx new file mode 100644 index 0000000000000..884ebf4f06041 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/error/page.tsx @@ -0,0 +1,7 @@ +'use cache' + +export default async function Page() { + throw new Error('kaputt!') + + return null +} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/layout.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-unused/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-unused/page.tsx new file mode 100644 index 0000000000000..eaed87c4a6bb9 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params-unused/page.tsx @@ -0,0 +1,9 @@ +'use cache' + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ n: string }> +}) { + return

search params not used

+} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params/page.tsx new file mode 100644 index 0000000000000..2e525c33ccabc --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/search-params/page.tsx @@ -0,0 +1,11 @@ +'use cache' + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ n: string }> +}) { + const { n } = await searchParams + + return

search param: {n}

+} 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 new file mode 100644 index 0000000000000..5746ea3d55f02 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise-nested/page.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { setTimeout } from 'timers/promises' + +async function getUncachedData() { + await setTimeout(0) + + return Math.random() +} + +const getCachedData = async (promise: Promise) => { + 'use cache' + + return await promise +} + +async function indirection(promise: Promise) { + 'use cache' + + return getCachedData(promise) +} + +export default async function Page() { + const data = await indirection(getUncachedData()) + + return

{data}

+} 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 new file mode 100644 index 0000000000000..7a18769bd24aa --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/uncached-promise/page.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { setTimeout } from 'timers/promises' + +async function fetchUncachedData() { + await setTimeout(0) + + return Math.random() +} + +const Foo = async ({ promise }) => { + 'use cache' + + return ( + <> +

{await promise}

+

{Math.random()}

+ + ) +} + +export default async function Page() { + return +} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/next.config.js b/test/e2e/app-dir/use-cache-hanging-inputs/next.config.js new file mode 100644 index 0000000000000..3dac20d4703cf --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + dynamicIO: true, + prerenderEarlyExit: false, + }, +} + +module.exports = nextConfig 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 new file mode 100644 index 0000000000000..b81ad36324ec3 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts @@ -0,0 +1,173 @@ +import { nextTestSetup } from 'e2e-utils' +import { + getRedboxDescription, + getRedboxSource, + openRedbox, + assertHasRedbox, + getRedboxTitle, + getRedboxTotalErrorCount, + assertNoRedbox, +} from 'next-test-utils' +import stripAnsi from 'strip-ansi' + +const expectedErrorMessage = + 'Error: 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', () => { + const { next, isNextDev, isTurbopack, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + skipStart: process.env.NEXT_TEST_MODE !== 'dev', + }) + + if (skipped) { + return + } + + if (isNextDev) { + describe('when searchParams are used inside of "use cache"', () => { + it('should show an error toast after a timeout', async () => { + const outputIndex = next.cliOutput.length + const browser = await next.browser('/search-params?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 errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toBe(`[ Cache ] ${expectedErrorMessage}`) + + // TODO(veil): This should have an error source if the source mapping works. + expect(errorSource).toBe(null) + + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + + // TODO(veil): Should include properly source mapped stack frames. + expect(cliOutput).toContain( + isTurbopack + ? `${expectedErrorMessage} + at [project]/app/search-params/page.tsx [app-rsc] (ecmascript)` + : `${expectedErrorMessage} + at eval (webpack-internal:///(rsc)/./app/search-params/page.tsx:16:97)` + ) + }, 180_000) + }) + + describe('when searchParams are unused inside of "use cache"', () => { + it('should not show an error', async () => { + const outputIndex = next.cliOutput.length + const browser = await next.browser('/search-params-unused?n=1') + + await assertNoRedbox(browser) + + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + + expect(cliOutput).not.toContain(expectedErrorMessage) + }) + }) + + describe('when an uncached promise is used inside of "use cache"', () => { + it('should show an error toast after a timeout', async () => { + const outputIndex = next.cliOutput.length + const browser = await next.browser('/uncached-promise') + + // The request is pending while we stall on the hanging inputs, and + // playwright will wait for the load even before continuing. So we don't + // need to wait for the "use cache" timeout of 50 seconds here. + + await openRedbox(browser) + + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toBe(`[ Cache ] ${expectedErrorMessage}`) + + // TODO(veil): This should have an error source if the source mapping works. + expect(errorSource).toBe(null) + + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + + // TODO(veil): Should include properly source mapped stack frames. + expect(cliOutput).toContain( + isTurbopack + ? `${expectedErrorMessage} + at [project]/app/uncached-promise/page.tsx [app-rsc] (ecmascript)` + : `${expectedErrorMessage} + at eval (webpack-internal:///(rsc)/./app/uncached-promise/page.tsx:26:97)` + ) + }, 180_000) + }) + + describe('when an uncached promise is used inside of a nested "use cache"', () => { + it('should show an error toast after a timeout', async () => { + const outputIndex = next.cliOutput.length + const browser = await next.browser('/uncached-promise-nested') + + // The request is pending while we stall on the hanging inputs, and + // playwright will wait for the load even before continuing. So we don't + // need to wait for the "use cache" timeout of 50 seconds here. + + await openRedbox(browser) + + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toBe(`[ Cache ] ${expectedErrorMessage}`) + + // TODO(veil): This should have an error source if the source mapping works. + expect(errorSource).toBe(null) + + const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex)) + + // TODO(veil): Should include properly source mapped stack frames. + expect(cliOutput).toContain( + isTurbopack + ? `${expectedErrorMessage} + at [project]/app/uncached-promise-nested/page.tsx [app-rsc] (ecmascript)` + : `${expectedErrorMessage} + at eval (webpack-internal:///(rsc)/./app/uncached-promise-nested/page.tsx:35:97)` + ) + }, 180_000) + }) + + describe('when an error is thrown', () => { + it('should show an error overlay with only one error', async () => { + const browser = await next.browser('/error') + + await assertHasRedbox(browser) + + const count = await getRedboxTotalErrorCount(browser) + const title = await getRedboxTitle(browser) + const description = await getRedboxDescription(browser) + + expect({ count, title, description }).toEqual({ + count: 1, + title: 'Unhandled Runtime Error', + description: '[ Cache ] Error: kaputt!', + }) + }) + }) + } else { + it('should fail the build with errors after a timeout', async () => { + const { cliOutput } = await next.build() + + expect(cliOutput).toInclude(expectedErrorMessage) + + expect(cliOutput).toInclude( + 'Error occurred prerendering page "/search-params"' + ) + + expect(cliOutput).toInclude( + 'Error occurred prerendering page "/uncached-promise"' + ) + + expect(cliOutput).toInclude( + 'Error occurred prerendering page "/uncached-promise-nested"' + ) + }, 180_000) + } +})