Skip to content

Commit 7b524fa

Browse files
authored
ppr: fail static generation if postponed & missing postpone data (#57786)
When postpone is caught by user code, this will cause PPR not to properly prerender the static parts and thus we need to fail the build. This also adds some messaging about how to fix the error. Prior to this change, catching code that would normally trigger `postpone ` would silently fail, but the build outputs would be incorrect as there's no postpone data available. Relands #57477 with additional tests & fixes
1 parent 1b0113b commit 7b524fa

File tree

18 files changed

+270
-34
lines changed

18 files changed

+270
-34
lines changed

errors/ppr-postpone-error.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
title: Understanding the postpone error triggered during static generation
3+
---
4+
5+
## Why This Error Occurred
6+
7+
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.
8+
9+
## Possible Ways to Fix It
10+
11+
To resolve this issue, ensure that you are not wrapping Next.js APIs that opt into dynamic rendering in a `try/catch` block.
12+
13+
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.

packages/next/src/client/components/maybe-postpone.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ export function maybePostpone(
1818
const React = require('react') as typeof import('react')
1919
if (typeof React.unstable_postpone !== 'function') return
2020

21+
// Keep track of if the postpone API has been called.
22+
staticGenerationStore.postponeWasTriggered = true
23+
2124
React.unstable_postpone(reason)
2225
}

packages/next/src/client/components/static-generation-async-storage.external.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface StaticGenerationStore {
2727
forceStatic?: boolean
2828
dynamicShouldError?: boolean
2929
pendingRevalidates?: Promise<any>[]
30+
postponeWasTriggered?: boolean
3031

3132
dynamicUsageDescription?: string
3233
dynamicUsageStack?: string

packages/next/src/export/helpers/is-dynamic-usage-error.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,4 @@ export const isDynamicUsageError = (err: any) =>
77
err.digest === DYNAMIC_ERROR_CODE ||
88
isNotFoundError(err) ||
99
err.digest === NEXT_DYNAMIC_NO_SSR_CODE ||
10-
isRedirectError(err) ||
11-
// TODO: (wyattjoh) remove once we bump react
12-
err.$$typeof === Symbol.for('react.postpone')
10+
isRedirectError(err)

packages/next/src/export/worker.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { exportPages } from './routes/pages'
3333
import { getParams } from './helpers/get-params'
3434
import { createIncrementalCache } from './helpers/create-incremental-cache'
3535
import { isPostpone } from '../server/lib/router-utils/is-postpone'
36+
import { isMissingPostponeDataError } from '../server/app-render/is-missing-postpone-error'
3637

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

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

314318
return { error: true }
315319
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { validateURL } from './validate-url'
6161
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
6262
import { handleAction } from './action-handler'
6363
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error'
64-
import { warn } from '../../build/output/log'
64+
import { warn, error } from '../../build/output/log'
6565
import { appendMutableCookies } from '../web/spec-extension/adapters/request-cookies'
6666
import { createServerInsertedHTML } from './server-inserted-html'
6767
import { getRequiredScripts } from './required-scripts'
@@ -72,6 +72,7 @@ import { createComponentTree } from './create-component-tree'
7272
import { getAssetQueryString } from './get-asset-query-string'
7373
import { setReferenceManifestsSingleton } from './action-encryption-utils'
7474
import { createStaticRenderer } from './static/static-renderer'
75+
import { MissingPostponeDataError } from './is-missing-postpone-error'
7576

7677
export type GetDynamicParamFromSegment = (
7778
// [slug] / [[slug]] / [...slug]
@@ -464,19 +465,27 @@ async function renderToHTMLOrFlightImpl(
464465
const capturedErrors: Error[] = []
465466
const allCapturedErrors: Error[] = []
466467
const isNextExport = !!renderOpts.nextExport
468+
const { staticGenerationStore, requestStore } = baseCtx
469+
const isStaticGeneration = staticGenerationStore.isStaticGeneration
470+
// when static generation fails during PPR, we log the errors separately. We intentionally
471+
// silence the error logger in this case to avoid double logging.
472+
const silenceStaticGenerationErrors = renderOpts.ppr && isStaticGeneration
473+
467474
const serverComponentsErrorHandler = createErrorHandler({
468475
_source: 'serverComponentsRenderer',
469476
dev,
470477
isNextExport,
471478
errorLogger: appDirDevErrorLogger,
472479
capturedErrors,
480+
silenceLogger: silenceStaticGenerationErrors,
473481
})
474482
const flightDataRendererErrorHandler = createErrorHandler({
475483
_source: 'flightDataRenderer',
476484
dev,
477485
isNextExport,
478486
errorLogger: appDirDevErrorLogger,
479487
capturedErrors,
488+
silenceLogger: silenceStaticGenerationErrors,
480489
})
481490
const htmlRendererErrorHandler = createErrorHandler({
482491
_source: 'htmlRenderer',
@@ -485,6 +494,7 @@ async function renderToHTMLOrFlightImpl(
485494
errorLogger: appDirDevErrorLogger,
486495
capturedErrors,
487496
allCapturedErrors,
497+
silenceLogger: silenceStaticGenerationErrors,
488498
})
489499

490500
patchFetch(ComponentMod)
@@ -520,7 +530,6 @@ async function renderToHTMLOrFlightImpl(
520530
)
521531
}
522532

523-
const { staticGenerationStore, requestStore } = baseCtx
524533
const { urlPathname } = staticGenerationStore
525534

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

557-
const isStaticGeneration = staticGenerationStore.isStaticGeneration
558-
559566
// During static generation we need to call the static generation bailout when reading searchParams
560567
const providedSearchParams = isStaticGeneration
561568
? createSearchParamsBailoutProxy()
@@ -778,15 +785,6 @@ async function renderToHTMLOrFlightImpl(
778785
throw err
779786
}
780787

781-
// If there was a postponed error that escaped, it means that there was
782-
// a postpone called without a wrapped suspense component.
783-
if (err.$$typeof === Symbol.for('react.postpone')) {
784-
// Ensure that we force the revalidation time to zero.
785-
staticGenerationStore.revalidate = 0
786-
787-
throw err
788-
}
789-
790788
if (err.digest === NEXT_DYNAMIC_NO_SSR_CODE) {
791789
warn(
792790
`Entire page ${pagePath} deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`,
@@ -1003,6 +1001,34 @@ async function renderToHTMLOrFlightImpl(
10031001
if (staticGenerationStore.isStaticGeneration) {
10041002
const htmlResult = await renderResult.toUnchunkedString(true)
10051003

1004+
if (
1005+
// if PPR is enabled
1006+
renderOpts.ppr &&
1007+
// and a call to `maybePostpone` happened
1008+
staticGenerationStore.postponeWasTriggered &&
1009+
// but there's no postpone state
1010+
!extraRenderResultMeta.postponed
1011+
) {
1012+
// a call to postpone was made but was caught and not detected by Next.js. We should fail the build immediately
1013+
// as we won't be able to generate the static part
1014+
warn('')
1015+
error(
1016+
`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`
1017+
)
1018+
1019+
if (capturedErrors.length > 0) {
1020+
warn(
1021+
'The following error was thrown during build, and may help identify the source of the issue:'
1022+
)
1023+
1024+
error(capturedErrors[0])
1025+
}
1026+
1027+
throw new MissingPostponeDataError(
1028+
`An unexpected error occurred while prerendering ${urlPathname}. Please check the logs above for more details.`
1029+
)
1030+
}
1031+
10061032
// if we encountered any unexpected errors during build
10071033
// we fail the prerendering phase and the build
10081034
if (capturedErrors.length > 0) {

packages/next/src/server/app-render/create-error-handler.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ export function createErrorHandler({
2424
errorLogger,
2525
capturedErrors,
2626
allCapturedErrors,
27+
silenceLogger,
2728
}: {
2829
_source: string
2930
dev?: boolean
3031
isNextExport?: boolean
3132
errorLogger?: (err: any) => Promise<void>
3233
capturedErrors: Error[]
3334
allCapturedErrors?: Error[]
35+
silenceLogger?: boolean
3436
}): ErrorHandler {
3537
return (err) => {
3638
if (allCapturedErrors) allCapturedErrors.push(err)
@@ -73,19 +75,21 @@ export function createErrorHandler({
7375
})
7476
}
7577

76-
if (errorLogger) {
77-
errorLogger(err).catch(() => {})
78-
} else {
79-
// The error logger is currently not provided in the edge runtime.
80-
// Use `log-app-dir-error` instead.
81-
// It won't log the source code, but the error will be more useful.
82-
if (process.env.NODE_ENV !== 'production') {
83-
const { logAppDirError } =
84-
require('../dev/log-app-dir-error') as typeof import('../dev/log-app-dir-error')
85-
logAppDirError(err)
86-
}
87-
if (process.env.NODE_ENV === 'production') {
88-
console.error(err)
78+
if (!silenceLogger) {
79+
if (errorLogger) {
80+
errorLogger(err).catch(() => {})
81+
} else {
82+
// The error logger is currently not provided in the edge runtime.
83+
// Use `log-app-dir-error` instead.
84+
// It won't log the source code, but the error will be more useful.
85+
if (process.env.NODE_ENV !== 'production') {
86+
const { logAppDirError } =
87+
require('../dev/log-app-dir-error') as typeof import('../dev/log-app-dir-error')
88+
logAppDirError(err)
89+
}
90+
if (process.env.NODE_ENV === 'production') {
91+
console.error(err)
92+
}
8993
}
9094
}
9195
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const MISSING_POSTPONE_DATA_ERROR = 'MISSING_POSTPONE_DATA_ERROR'
2+
3+
export class MissingPostponeDataError extends Error {
4+
digest: typeof MISSING_POSTPONE_DATA_ERROR = MISSING_POSTPONE_DATA_ERROR
5+
6+
constructor(type: string) {
7+
super(`Missing Postpone Data Error: ${type}`)
8+
}
9+
}
10+
11+
export const isMissingPostponeDataError = (err: any) =>
12+
err.digest === MISSING_POSTPONE_DATA_ERROR

packages/next/src/server/base-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2682,7 +2682,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
26822682
}
26832683

26842684
if (isDataReq) {
2685-
// If this isn't a prefetch and this isn't a resume request, we want to
2685+
// If this isn't a prefetch and this isn't a resume request, we want to
26862686
// respond with the dynamic flight data. In the case that this is a
26872687
// resume request the page data will already be dynamic.
26882688
if (!isAppPrefetch && !resumed) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react'
2+
3+
export default function Root({ children }) {
4+
return (
5+
<html>
6+
<body>{children}</body>
7+
</html>
8+
)
9+
}

0 commit comments

Comments
 (0)