From 9dbf753252abe4bacb8a50e22b02a102cac3d178 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 26 Aug 2024 14:45:44 -0700 Subject: [PATCH] handle uncaught exceptions in worker Previously any uncaught exception in a build worker would lead to the worker process being exited. This change adds a custom handler to only terminate the process if the error is a Next.js specific API that expects to be called in a context where the framework can observe it. Other errors that are uncaught are logged but won't terminate the worker process. --- .../export/helpers/is-dynamic-usage-error.ts | 4 +- packages/next/src/export/worker.ts | 14 +++ .../server/app-render/dynamic-rendering.ts | 37 +++++- .../build-output/app/uncaught-error/page.tsx | 20 ++++ .../app-dir/build-output/index.test.ts | 112 ++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 test/production/app-dir/build-output/app/uncaught-error/page.tsx diff --git a/packages/next/src/export/helpers/is-dynamic-usage-error.ts b/packages/next/src/export/helpers/is-dynamic-usage-error.ts index d6df2454094af..e2ebd896eae6e 100644 --- a/packages/next/src/export/helpers/is-dynamic-usage-error.ts +++ b/packages/next/src/export/helpers/is-dynamic-usage-error.ts @@ -1,8 +1,10 @@ import { isDynamicServerError } from '../../client/components/hooks-server-context' import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' import { isNextRouterError } from '../../client/components/is-next-router-error' +import { isDynamicPostpone } from '../../server/app-render/dynamic-rendering' export const isDynamicUsageError = (err: unknown) => isDynamicServerError(err) || isBailoutToCSRError(err) || - isNextRouterError(err) + isNextRouterError(err) || + isDynamicPostpone(err) diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 15fe38ac90d6e..2120f68b900f4 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -548,3 +548,17 @@ process.on('rejectionHandled', () => { // prefetching patterns to avoid waterfalls. We ignore logging these. // We should've already errored in anyway unhandledRejection. }) + +const FATAL_UNHANDLED_NEXT_API_EXIT_CODE = 78 + +process.on('uncaughtException', (err) => { + if (isDynamicUsageError(err)) { + console.error( + 'A Next.js API that uses exceptions to signal framework behavior was uncaught. This suggests improper usage of a Next.js API. The original error is printed below and the build will now exit.' + ) + console.error(err) + process.exit(FATAL_UNHANDLED_NEXT_API_EXIT_CODE) + } else { + console.error(err) + } +}) diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index c4646e56c565b..9f2892c44f4f3 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -192,12 +192,45 @@ function postponeWithTracking( expression, }) } - const reason = + + React.unstable_postpone(createPostponeReason(route, expression)) +} + +function createPostponeReason(route: string, expression: string) { + return ( `Route ${route} needs to bail out of prerendering at this point because it used ${expression}. ` + `React throws this special object to indicate where. It should not be caught by ` + `your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error` + ) +} + +export function isDynamicPostpone(err: unknown) { + console.log(err) + if ( + typeof err === 'object' && + err !== null && + typeof (err as any).message === 'string' + ) { + return isDynamicPostponeReason((err as any).message) + } + return false +} + +function isDynamicPostponeReason(reason: string) { + return ( + reason.includes( + 'needs to bail out of prerendering at this point because it used' + ) && + reason.includes( + 'Learn more: https://nextjs.org/docs/messages/ppr-caught-error' + ) + ) +} - React.unstable_postpone(reason) +if (isDynamicPostponeReason(createPostponeReason('%%%', '^^^')) === false) { + throw new Error( + 'Invariant: isDynamicPostpone misidentified a postpone reason. This is a bug in Next.js' + ) } export function usedDynamicAPIs( diff --git a/test/production/app-dir/build-output/app/uncaught-error/page.tsx b/test/production/app-dir/build-output/app/uncaught-error/page.tsx new file mode 100644 index 0000000000000..674152d23693a --- /dev/null +++ b/test/production/app-dir/build-output/app/uncaught-error/page.tsx @@ -0,0 +1,20 @@ +import { PHASE_PRODUCTION_BUILD } from 'next/constants' + +export default function Page() { + setTimeout(() => { + throw new Error('Boom') + }, 0) + return ( + <> +

+ This page should be statically generated even though we threw an + unhandled exception in a setTimeout. +

+

+ {process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD + ? 'at buildtime' + : 'at runtime'} +

+ + ) +} diff --git a/test/production/app-dir/build-output/index.test.ts b/test/production/app-dir/build-output/index.test.ts index 07d1e7e4aab6e..7ec57aa6e47a8 100644 --- a/test/production/app-dir/build-output/index.test.ts +++ b/test/production/app-dir/build-output/index.test.ts @@ -1,5 +1,6 @@ import { nextTestSetup } from 'e2e-utils' import stripAnsi from 'strip-ansi' +import { outdent } from 'outdent' describe('production - app dir - build output', () => { const { next } = nextTestSetup({ @@ -38,4 +39,115 @@ describe('production - app dir - build output', () => { // output type expect(output).toContain('○ (Static) prerendered as static content') }) + + it('should log errors not caught by the worker without terminating the process', async () => { + expect(output).toContain('Error: Boom') + expect(output).not.toContain('Static worker exited with code: 78') + + const $ = await next.render$('/uncaught-error') + expect($('#sentinel').text()).toEqual('at buildtime') + }) + + it('should fail the build if you use a dynamic API outside of a render context - cookies', async () => { + await next.stop() + await next.patchFile( + 'app/out-of-band-dynamic-api/page.tsx', + outdent` + import { cookies } from 'next/headers' + + export default async function Page() { + setTimeout(() => { + cookies(); + }, 0) + return
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + await next.deleteFile('app/out-of-band-dynamic-api/page.tsx') + + expect(cliOutput).toContain('Static worker exited with code: 78') + }) + + it('should fail the build if you use a dynamic API outside of a render context - headers', async () => { + await next.stop() + await next.patchFile( + 'app/out-of-band-dynamic-api/page.tsx', + outdent` + import { headers } from 'next/headers' + + export default async function Page({ searchParams }) { + setTimeout(() => { + headers() + }, 0) + return
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + await next.deleteFile('app/out-of-band-dynamic-api/page.tsx') + + expect(cliOutput).toContain('Static worker exited with code: 78') + }) + + it('should fail the build if you use a dynamic API outside of a render context - searchParams', async () => { + await next.stop() + await next.patchFile( + 'app/out-of-band-dynamic-api/page.tsx', + outdent` + export default async function Page({ searchParams }) { + setTimeout(() => { + searchParams.foo + }, 0) + return
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + await next.deleteFile('app/out-of-band-dynamic-api/page.tsx') + + expect(cliOutput).toContain('Static worker exited with code: 78') + }) + + it('should fail the build if you use a dynamic API outside of a render context - redirect', async () => { + await next.stop() + await next.patchFile( + 'app/out-of-band-dynamic-api/page.tsx', + outdent` + import { redirect } from 'next/navigation' + + export default async function Page({ searchParams }) { + setTimeout(() => { + redirect('/whatever') + }, 0) + return
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + await next.deleteFile('app/out-of-band-dynamic-api/page.tsx') + + expect(cliOutput).toContain('Static worker exited with code: 78') + }) + + it('should fail the build if you use a dynamic API outside of a render context - notFound', async () => { + await next.stop() + await next.patchFile( + 'app/out-of-band-dynamic-api/page.tsx', + outdent` + import { notFound } from 'next/navigation' + + export default async function Page({ searchParams }) { + setTimeout(() => { + notFound() + }, 0) + return
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + await next.deleteFile('app/out-of-band-dynamic-api/page.tsx') + + expect(cliOutput).toContain('Static worker exited with code: 78') + }) })