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

ppr: fail static generation if postponed & missing postpone data #57786

Merged
merged 11 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions errors/ppr-postpone-error.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
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, we will not be able to generate any static data, and your build will fail.

## 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.

If you do wrap these APIs in a try/catch, make sure you re-throw the original error so it can be caught by Next.
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)
12 changes: 8 additions & 4 deletions packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { exportPages } from './routes/pages'
import { getParams } from './helpers/get-params'
import { createIncrementalCache } from './helpers/create-incremental-cache'
import { isPostpone } from '../server/lib/router-utils/is-postpone'
import { isMissingPostponeDataError } from '../server/app-render/is-missing-postpone-error'

const envConfig = require('../shared/lib/runtime-config.external')

Expand Down Expand Up @@ -306,10 +307,13 @@ async function exportPageImpl(
fileWriter
)
} catch (err) {
console.error(
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n` +
(isError(err) && err.stack ? err.stack : err)
)
// if this is a postpone error, it's logged elsewhere, so no need to log it again here
if (!isMissingPostponeDataError(err)) {
console.error(
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n` +
(isError(err) && err.stack ? err.stack : err)
)
}

return { error: true }
}
Expand Down
52 changes: 39 additions & 13 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 All @@ -72,6 +72,7 @@ import { createComponentTree } from './create-component-tree'
import { getAssetQueryString } from './get-asset-query-string'
import { setReferenceManifestsSingleton } from './action-encryption-utils'
import { createStaticRenderer } from './static/static-renderer'
import { MissingPostponeDataError } from './is-missing-postpone-error'

export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
Expand Down Expand Up @@ -464,19 +465,27 @@ async function renderToHTMLOrFlightImpl(
const capturedErrors: Error[] = []
const allCapturedErrors: Error[] = []
const isNextExport = !!renderOpts.nextExport
const { staticGenerationStore, requestStore } = baseCtx
const isStaticGeneration = staticGenerationStore.isStaticGeneration
// when static generation fails during PPR, we log the errors separately. We intentionally
// silence the error logger in this case to avoid double logging.
const silenceStaticGenerationErrors = renderOpts.ppr && isStaticGeneration
ztanner marked this conversation as resolved.
Show resolved Hide resolved

const serverComponentsErrorHandler = createErrorHandler({
_source: 'serverComponentsRenderer',
dev,
isNextExport,
errorLogger: appDirDevErrorLogger,
capturedErrors,
silenceLogger: silenceStaticGenerationErrors,
})
const flightDataRendererErrorHandler = createErrorHandler({
_source: 'flightDataRenderer',
dev,
isNextExport,
errorLogger: appDirDevErrorLogger,
capturedErrors,
silenceLogger: silenceStaticGenerationErrors,
})
const htmlRendererErrorHandler = createErrorHandler({
_source: 'htmlRenderer',
Expand All @@ -485,6 +494,7 @@ async function renderToHTMLOrFlightImpl(
errorLogger: appDirDevErrorLogger,
capturedErrors,
allCapturedErrors,
silenceLogger: silenceStaticGenerationErrors,
})

patchFetch(ComponentMod)
Expand Down Expand Up @@ -520,7 +530,6 @@ async function renderToHTMLOrFlightImpl(
)
}

const { staticGenerationStore, requestStore } = baseCtx
const { urlPathname } = staticGenerationStore

staticGenerationStore.fetchMetrics = []
Expand Down Expand Up @@ -554,8 +563,6 @@ async function renderToHTMLOrFlightImpl(
requestId = require('next/dist/compiled/nanoid').nanoid()
}

const isStaticGeneration = staticGenerationStore.isStaticGeneration

// During static generation we need to call the static generation bailout when reading searchParams
const providedSearchParams = isStaticGeneration
? createSearchParamsBailoutProxy()
Expand Down Expand Up @@ -778,15 +785,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 +1001,34 @@ async function renderToHTMLOrFlightImpl(
if (staticGenerationStore.isStaticGeneration) {
const htmlResult = await renderResult.toUnchunkedString(true)

if (
// if PPR is enabled
renderOpts.ppr &&
// and a call to `maybePostpone` happened
staticGenerationStore.postponeWasTriggered &&
// but there's no postpone state
!extraRenderResultMeta.postponed
) {
// a call to postpone was made but was caught and not detected by Next.js. We should fail the build immediately
// as we won't be able to generate the static part
warn('')
error(
`Postpone signal was caught while rendering ${urlPathname}. Check to see if you're try/catching a Next.js API such as headers / cookies, or a fetch with "no-store". Learn more: https://nextjs.org/docs/messages/ppr-postpone-errors`
)

if (capturedErrors.length > 0) {
warn(
'The following error was thrown during build, and may help identify the source of the issue:'
)

error(capturedErrors[0])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we not printing all errors?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the existing behavior for regular prerender failures below (ref) -- my reasoning was that we'll get duplicated errors from the serverComponentsRenderer and flightDataRenderer. We could add additional branching for PPR specifically to dedupe, but was thinking it's not worth the additional complexity, since these errors will still be surfaced by subsequent builds.

}

throw new MissingPostponeDataError(
`An unexpected error occurred while prerendering ${urlPathname}. Please check the logs above for more details.`
)
}

// if we encountered any unexpected errors during build
// we fail the prerendering phase and the build
if (capturedErrors.length > 0) {
Expand Down
30 changes: 17 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,15 @@ export function createErrorHandler({
errorLogger,
capturedErrors,
allCapturedErrors,
silenceLogger,
}: {
_source: string
dev?: boolean
isNextExport?: boolean
errorLogger?: (err: any) => Promise<void>
capturedErrors: Error[]
allCapturedErrors?: Error[]
silenceLogger?: boolean
}): ErrorHandler {
return (err) => {
if (allCapturedErrors) allCapturedErrors.push(err)
Expand Down Expand Up @@ -73,19 +75,21 @@ 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 (!silenceLogger) {
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
12 changes: 12 additions & 0 deletions packages/next/src/server/app-render/is-missing-postpone-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const MISSING_POSTPONE_DATA_ERROR = 'MISSING_POSTPONE_DATA_ERROR'

export class MissingPostponeDataError extends Error {
digest: typeof MISSING_POSTPONE_DATA_ERROR = MISSING_POSTPONE_DATA_ERROR

constructor(type: string) {
super(`Missing Postpone Data Error: ${type}`)
}
}

export const isMissingPostponeDataError = (err: any) =>
err.digest === MISSING_POSTPONE_DATA_ERROR
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 @@ -2682,7 +2682,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
9 changes: 9 additions & 0 deletions test/e2e/app-dir/ppr-errors/app/layout.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react'

export default function Root({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import { cookies } from 'next/headers'

export default async function Page() {
try {
cookies()
} catch (err) {
throw new Error(
"Throwing a new error from 'no-suspense-boundary-re-throwing-error'"
)
}
return <div>Hello World</div>
}
9 changes: 9 additions & 0 deletions test/e2e/app-dir/ppr-errors/app/no-suspense-boundary/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react'
import { cookies } from 'next/headers'

export default async function Page() {
try {
cookies()
} catch (err) {}
return <div>Hello World</div>
}
17 changes: 17 additions & 0 deletions test/e2e/app-dir/ppr-errors/app/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { Suspense } from 'react'
import { cookies } from 'next/headers'

export default async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Foobar />
</Suspense>
)
}

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

export default async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Foobar />
</Suspense>
)
}

async function Foobar() {
try {
cookies()
} catch (err) {
throw new Error('The original error was caught and rethrown.')
}
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React, { Suspense } from 'react'

export default async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Foobar />
</Suspense>
)
}

async function Foobar() {
throw new Error('Kaboom')
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/ppr-errors/app/regular-error/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default async function Page() {
throw new Error('Regular Prerender Error')
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/ppr-errors/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
ppr: true,
},
}

module.exports = nextConfig
Loading
Loading