Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add better messaging around wrapping postpone with try/catch #57446

Merged
merged 12 commits into from
Oct 26, 2023
11 changes: 11 additions & 0 deletions errors/ppr-postpone-error.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: Understanding the postpone error triggered during static generation
---

## Why This Error Occurred

When Partial Prerendering (PPR) is enabled, using APIs that opt into Dynamic Rendering like `cookies`, `headers`, or `fetch` (such as with `cache: 'no-store'` or `revalidate: 0`) will cause Next.js to throw a special error to know which part of the page cannot be statically generated. If you catch this error, Next.js will not be able to recognize that dynamic content was used and will only be able to statically render the page, opting you out of partial prerendering and lead to potentially undefined behavior.

## Possible Ways to Fix It

To resolve this issue, ensure that you are not wrapping Next.js APIs that opt into dynamic rendering in a `try/catch` block.
3 changes: 3 additions & 0 deletions packages/next/src/client/components/maybe-postpone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ export function maybePostpone(
const React = require('react') as typeof import('react')
if (typeof React.unstable_postpone !== 'function') return

// Keep track of if the postpone API has been called.
staticGenerationStore.postponeWasTriggered = true

React.unstable_postpone(reason)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface StaticGenerationStore {
forceStatic?: boolean
dynamicShouldError?: boolean
pendingRevalidates?: Promise<any>[]
postponeWasTriggered?: boolean

dynamicUsageDescription?: string
dynamicUsageStack?: string
Expand Down
4 changes: 1 addition & 3 deletions packages/next/src/export/helpers/is-dynamic-usage-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,4 @@ export const isDynamicUsageError = (err: any) =>
err.digest === DYNAMIC_ERROR_CODE ||
isNotFoundError(err) ||
err.digest === NEXT_DYNAMIC_NO_SSR_CODE ||
isRedirectError(err) ||
// TODO: (wyattjoh) remove once we bump react
err.$$typeof === Symbol.for('react.postpone')
isRedirectError(err)
7 changes: 5 additions & 2 deletions packages/next/src/export/routes/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,13 @@ export async function exportAppPage(
revalidate,
}
} catch (err: any) {
if (!isDynamicUsageError(err)) {
// if the error isn't a special dynamic usage error (caught by Next)
// we also do not throw the error if it occurred while attempting a postpone
// since those will be captured and logged during build/ISR
if (!isDynamicUsageError(err) && !renderOpts.hasPostponeErrors) {
throw err
}

return { revalidate: 0 }
return { revalidate: 0, hasEmptyPrelude: true }
}
}
39 changes: 29 additions & 10 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { validateURL } from './validate-url'
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
import { handleAction } from './action-handler'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error'
import { warn } from '../../build/output/log'
import { warn, error } from '../../build/output/log'
import { appendMutableCookies } from '../web/spec-extension/adapters/request-cookies'
import { createServerInsertedHTML } from './server-inserted-html'
import { getRequiredScripts } from './required-scripts'
Expand Down Expand Up @@ -463,20 +463,24 @@ async function renderToHTMLOrFlightImpl(

const capturedErrors: Error[] = []
const allCapturedErrors: Error[] = []
const postponeErrors: Error[] = []
const isNextExport = !!renderOpts.nextExport
const serverComponentsErrorHandler = createErrorHandler({
_source: 'serverComponentsRenderer',
dev,
isNextExport,
postponeErrors: renderOpts.ppr ? postponeErrors : undefined,
errorLogger: appDirDevErrorLogger,
capturedErrors,
skipLogging: renderOpts.ppr,
})
const flightDataRendererErrorHandler = createErrorHandler({
_source: 'flightDataRenderer',
dev,
isNextExport,
errorLogger: appDirDevErrorLogger,
capturedErrors,
skipLogging: renderOpts.ppr,
})
const htmlRendererErrorHandler = createErrorHandler({
_source: 'htmlRenderer',
Expand All @@ -485,6 +489,7 @@ async function renderToHTMLOrFlightImpl(
errorLogger: appDirDevErrorLogger,
capturedErrors,
allCapturedErrors,
skipLogging: renderOpts.ppr,
})

patchFetch(ComponentMod)
Expand Down Expand Up @@ -778,15 +783,6 @@ async function renderToHTMLOrFlightImpl(
throw err
}

// If there was a postponed error that escaped, it means that there was
// a postpone called without a wrapped suspense component.
if (err.$$typeof === Symbol.for('react.postpone')) {
// Ensure that we force the revalidation time to zero.
staticGenerationStore.revalidate = 0

throw err
}

if (err.digest === NEXT_DYNAMIC_NO_SSR_CODE) {
warn(
`Entire page ${pagePath} deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`,
Expand Down Expand Up @@ -1003,6 +999,29 @@ async function renderToHTMLOrFlightImpl(
if (staticGenerationStore.isStaticGeneration) {
const htmlResult = await renderResult.toUnchunkedString(true)

if (renderOpts.ppr && postponeErrors.length > 0) {
renderOpts.hasPostponeErrors = true
}

if (
renderOpts.ppr &&
staticGenerationStore.postponeWasTriggered &&
!extraRenderResultMeta.postponed
) {
warn('')
warn(
`${urlPathname} opted out of partial prerendering because the postpone signal was intercepted by a try/catch in your application code.`
)

if (postponeErrors.length > 0) {
warn(
'The following errors were re-thrown, and might help find the location of the try/catch that triggered this.'
)
for (let i = 0; i < postponeErrors.length; i++) {
error(`${postponeErrors[i].stack?.split('\n').join('\n ')}`)
}
}
}
// if we encountered any unexpected errors during build
// we fail the prerendering phase and the build
if (capturedErrors.length > 0) {
Expand Down
36 changes: 23 additions & 13 deletions packages/next/src/server/app-render/create-error-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ export function createErrorHandler({
errorLogger,
capturedErrors,
allCapturedErrors,
postponeErrors,
skipLogging,
}: {
_source: string
dev?: boolean
isNextExport?: boolean
errorLogger?: (err: any) => Promise<void>
capturedErrors: Error[]
allCapturedErrors?: Error[]
postponeErrors?: Error[]
skipLogging?: boolean
}): ErrorHandler {
return (err) => {
if (allCapturedErrors) allCapturedErrors.push(err)
Expand Down Expand Up @@ -73,19 +77,25 @@ export function createErrorHandler({
})
}

if (errorLogger) {
errorLogger(err).catch(() => {})
} else {
// The error logger is currently not provided in the edge runtime.
// Use `log-app-dir-error` instead.
// It won't log the source code, but the error will be more useful.
if (process.env.NODE_ENV !== 'production') {
const { logAppDirError } =
require('../dev/log-app-dir-error') as typeof import('../dev/log-app-dir-error')
logAppDirError(err)
}
if (process.env.NODE_ENV === 'production') {
console.error(err)
if (postponeErrors) {
postponeErrors.push(err)
}

if (!skipLogging) {
if (errorLogger) {
errorLogger(err).catch(() => {})
} else {
// The error logger is currently not provided in the edge runtime.
// Use `log-app-dir-error` instead.
// It won't log the source code, but the error will be more useful.
if (process.env.NODE_ENV !== 'production') {
const { logAppDirError } =
require('../dev/log-app-dir-error') as typeof import('../dev/log-app-dir-error')
logAppDirError(err)
}
if (process.env.NODE_ENV === 'production') {
console.error(err)
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export interface RenderOptsPartial {
isPrefetch?: boolean
ppr: boolean
postponed?: string
hasPostponeErrors?: boolean
}

export type RenderOpts = LoadComponentsReturnType<AppPageModule> &
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2664,7 +2664,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
}

if (isDataReq) {
// If this isn't a prefetch and this isn't a resume request, we want to
// If this isn't a prefetch and this isn't a resume request, we want to
// respond with the dynamic flight data. In the case that this is a
// resume request the page data will already be dynamic.
if (!isAppPrefetch && !resumed) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { Suspense } from 'react'
import { cookies } from 'next/headers'

export default async function Page() {
return (
<>
<h2>Dynamic Component Catching Errors</h2>
<p>
This shows the dynamic component that reads cookies but wraps the read
in a try/catch. This test does not re-throw the caught error.
</p>
<div id="container">
<Suspense fallback={<div>Loading...</div>}>
<Foobar />
</Suspense>
</div>
</>
)
}

async function Foobar() {
try {
cookies()
} catch (err) {}
return null
}
34 changes: 34 additions & 0 deletions test/e2e/app-dir/ppr/app/suspense/node/cookies-error/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { Suspense } from 'react'
import { cookies } from 'next/headers'

import { Dynamic } from '../../../../components/dynamic'

export default async function Page() {
return (
<>
<h2>Dynamic Component Catching Errors</h2>
<p>
This shows the dynamic component that reads cookies but wraps the read
in a try/catch. This test re-throws the error that is caught.
</p>
<div id="container">
<Suspense fallback={<Dynamic fallback />}>
<Dynamic catchErrors />
</Suspense>

<Suspense fallback={<div>Loading...</div>}>
<Foobar />
</Suspense>
</div>
</>
)
}

async function Foobar() {
try {
cookies()
} catch (err) {
throw new Error('You are not signed in')
}
return null
}
33 changes: 33 additions & 0 deletions test/e2e/app-dir/ppr/app/suspense/node/fetch-error/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { Suspense } from 'react'

import { Dynamic } from '../../../../components/dynamic'

export default async function Page() {
const getData = () =>
fetch('https://example.vercel.sh', {
cache: 'no-store',
})
.then((res) => res.text())
.then((text) => new Promise((res) => setTimeout(() => res(text), 1000)))

try {
await getData()
} catch (err) {
throw new Error('Fetch failed')
}

return (
<>
<h2>Dynamic Component Catching Errors</h2>
<p>
This shows the dynamic component that reads cookies but wraps the read
in a try/catch.
</p>
<div id="container">
<Suspense fallback={<Dynamic fallback />}>
<Dynamic catchErrors />
</Suspense>
</div>
</>
)
}
14 changes: 11 additions & 3 deletions test/e2e/app-dir/ppr/components/dynamic.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import React, { use } from 'react'
import { cookies } from 'next/headers'
import { Delay, Login } from './state'

export function Dynamic({ fallback }) {
export function Dynamic({ fallback, catchErrors }) {
const dynamic = fallback !== true

let signedIn
let active
if (dynamic) {
signedIn = cookies().has('session') ? true : false
active = cookies().has('delay') ? true : false
if (catchErrors) {
try {
signedIn = cookies().has('session') ? true : false
active = cookies().has('delay') ? true : false
} catch (err) {}
} else {
signedIn = cookies().has('session') ? true : false
active = cookies().has('delay') ? true : false
}

if (active) {
use(new Promise((resolve) => setTimeout(resolve, 1000)))
}
Expand Down
22 changes: 21 additions & 1 deletion test/e2e/app-dir/ppr/ppr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,27 @@ createNextDescribe(
files: __dirname,
skipDeployment: true,
},
({ next, isNextDev }) => {
({ next, isNextDev, isNextStart }) => {
if (isNextStart) {
it("should log a warning when `unstable_postpone` is called but there's no postpone state", async () => {
// Three different pages wrap APIs that use `unstable_postpone` in a try catch to trigger these errors
expect(next.cliOutput).toContain(
'/suspense/node/cookies-error-no-throw opted out of partial prerendering because the postpone signal was intercepted by a try/catch in your application code.'
)
expect(next.cliOutput).toContain(
'/suspense/node/fetch-error opted out of partial prerendering because the postpone signal was intercepted by a try/catch in your application code.'
)
expect(next.cliOutput).toContain(
'/suspense/node/cookies-error opted out of partial prerendering because the postpone signal was intercepted by a try/catch in your application code.'
)

// fetch-error re-throws the error after catching it, so we want to make sure that's retained in the logs
expect(next.cliOutput).toContain(
'The following errors were re-thrown, and might help find the location of the try/catch that triggered this.'
)
expect(next.cliOutput).toContain('Error: You are not signed in')
})
}
describe.each([
{ pathname: '/suspense/node' },
{ pathname: '/suspense/node/nested/1' },
Expand Down