Skip to content

Commit

Permalink
Reapply "feat(app-router): introduce `experimental.missingSuspenseWit…
Browse files Browse the repository at this point in the history
…hCSRBailout` flag" (#60508) (#60751)

This reapplies the `experimental.missingSuspenseWithCSRBailout` option
to bail out during build if there was a missing suspense boundary when
using something that bails out to client side rendering (like
`useSearchParams()`). See #57642

Closes [NEXT-1770](https://linear.app/vercel/issue/NEXT-1770)
  • Loading branch information
wyattjoh authored Jan 17, 2024
1 parent 7f3d909 commit dda1870
Show file tree
Hide file tree
Showing 41 changed files with 625 additions and 430 deletions.
15 changes: 15 additions & 0 deletions errors/missing-suspense-with-csr-bailout.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: Missing Suspense with CSR Bailout
---

#### Why This Error Occurred

Certain methods like `useSearchParams()` opt Next.js into client-side rendering. Without a suspense boundary, this will opt the entire page into client-side rendering, which is likely not intended.

#### Possible Ways to Fix It

Make sure that the method is wrapped in a suspense boundary. This way Next.js will only opt the component into client-side rendering up to the suspense boundary.

### Useful Links

- [`useSearchParams`](https://nextjs.org/docs/app/api-reference/functions/use-search-params)
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { throwWithNoSSR } from '../../shared/lib/lazy-dynamic/no-ssr-error'
import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'

export function bailoutToClientRendering(): void | never {
export function bailoutToClientRendering(reason: string): void | never {
const staticGenerationStore = staticGenerationAsyncStorage.getStore()

if (staticGenerationStore?.forceStatic) {
return
}
if (staticGenerationStore?.forceStatic) return

if (staticGenerationStore?.isStaticGeneration) {
throwWithNoSSR()
}
if (staticGenerationStore?.isStaticGeneration)
throw new BailoutToCSRError(reason)
}
19 changes: 16 additions & 3 deletions packages/next/src/client/components/hooks-server-context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
export const DYNAMIC_ERROR_CODE = 'DYNAMIC_SERVER_USAGE'
const DYNAMIC_ERROR_CODE = 'DYNAMIC_SERVER_USAGE'

export class DynamicServerError extends Error {
digest: typeof DYNAMIC_ERROR_CODE = DYNAMIC_ERROR_CODE

constructor(type: string) {
super(`Dynamic server usage: ${type}`)
constructor(public readonly description: string) {
super(`Dynamic server usage: ${description}`)
}
}

export function isDynamicServerError(err: unknown): err is DynamicServerError {
if (
typeof err !== 'object' ||
err === null ||
!('digest' in err) ||
typeof err.digest !== 'string'
) {
return false
}

return err.digest === DYNAMIC_ERROR_CODE
}
2 changes: 1 addition & 1 deletion packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function useSearchParams(): ReadonlyURLSearchParams {
const { bailoutToClientRendering } =
require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering')
// TODO-APP: handle dynamic = 'force-static' here and on the client
bailoutToClientRendering()
bailoutToClientRendering('useSearchParams()')
}

return readonlySearchParams
Expand Down
8 changes: 6 additions & 2 deletions packages/next/src/client/components/not-found.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export function notFound(): never {
* @param error the error that may reference a not found error
* @returns true if the error is a not found error
*/
export function isNotFoundError(error: any): error is NotFoundError {
return error?.digest === NOT_FOUND_ERROR_CODE
export function isNotFoundError(error: unknown): error is NotFoundError {
if (typeof error !== 'object' || error === null || !('digest' in error)) {
return false
}

return error.digest === NOT_FOUND_ERROR_CODE
}
16 changes: 10 additions & 6 deletions packages/next/src/client/components/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,18 @@ export function permanentRedirect(
* @returns true if the error is a redirect error
*/
export function isRedirectError<U extends string>(
error: any
error: unknown
): error is RedirectError<U> {
if (typeof error?.digest !== 'string') return false
if (
typeof error !== 'object' ||
error === null ||
!('digest' in error) ||
typeof error.digest !== 'string'
) {
return false
}

const [errorCode, type, destination, status] = (error.digest as string).split(
';',
4
)
const [errorCode, type, destination, status] = error.digest.split(';', 4)

const statusCode = Number(status)

Expand Down
14 changes: 13 additions & 1 deletion packages/next/src/client/components/static-generation-bailout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ import type { AppConfigDynamic } from '../../build/utils'
import { DynamicServerError } from './hooks-server-context'
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'

const NEXT_STATIC_GEN_BAILOUT = 'NEXT_STATIC_GEN_BAILOUT'

class StaticGenBailoutError extends Error {
code = 'NEXT_STATIC_GEN_BAILOUT'
public readonly code = NEXT_STATIC_GEN_BAILOUT
}

export function isStaticGenBailoutError(
error: unknown
): error is StaticGenBailoutError {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return false
}

return error.code === NEXT_STATIC_GEN_BAILOUT
}

type BailoutOpts = { dynamic?: AppConfigDynamic; link?: string }
Expand Down
6 changes: 3 additions & 3 deletions packages/next/src/client/on-recoverable-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isBailoutCSRError } from '../shared/lib/lazy-dynamic/no-ssr-error'
import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr'

export default function onRecoverableError(err: any) {
export default function onRecoverableError(err: unknown) {
// Using default react onRecoverableError
// x-ref: https://github.com/facebook/react/blob/d4bc16a7d69eb2ea38a88c8ac0b461d5f72cdcab/packages/react-dom/src/client/ReactDOMRoot.js#L83
const defaultOnRecoverableError =
Expand All @@ -13,7 +13,7 @@ export default function onRecoverableError(err: any) {
}

// Skip certain custom errors which are not expected to be reported on client
if (isBailoutCSRError(err)) return
if (isBailoutToCSRError(err)) return

defaultOnRecoverableError(err)
}
10 changes: 5 additions & 5 deletions packages/next/src/export/helpers/is-dynamic-usage-error.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { DYNAMIC_ERROR_CODE } from '../../client/components/hooks-server-context'
import { isDynamicServerError } from '../../client/components/hooks-server-context'
import { isNotFoundError } from '../../client/components/not-found'
import { isRedirectError } from '../../client/components/redirect'
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'

export const isDynamicUsageError = (err: any) =>
err.digest === DYNAMIC_ERROR_CODE ||
export const isDynamicUsageError = (err: unknown) =>
isDynamicServerError(err) ||
isBailoutToCSRError(err) ||
isNotFoundError(err) ||
isBailoutCSRError(err) ||
isRedirectError(err)
6 changes: 5 additions & 1 deletion packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,11 @@ export async function exportAppImpl(
: {}),
strictNextHead: !!nextConfig.experimental.strictNextHead,
deploymentId: nextConfig.experimental.deploymentId,
experimental: { ppr: nextConfig.experimental.ppr === true },
experimental: {
ppr: nextConfig.experimental.ppr === true,
missingSuspenseWithCSRBailout:
nextConfig.experimental.missingSuspenseWithCSRBailout === true,
},
}

const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig
Expand Down
12 changes: 11 additions & 1 deletion packages/next/src/export/routes/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../lib/constants'
import { hasNextSupport } from '../../telemetry/ci-info'
import { lazyRenderAppPage } from '../../server/future/route-modules/app-page/module.render'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'

export const enum ExportedAppPageFiles {
HTML = 'HTML',
Expand Down Expand Up @@ -139,11 +140,20 @@ export async function exportAppPage(
hasPostponed: Boolean(postponed),
revalidate,
}
} catch (err: any) {
} catch (err) {
if (!isDynamicUsageError(err)) {
throw err
}

// If enabled, we should fail rendering if a client side rendering bailout
// occurred at the page level.
if (
renderOpts.experimental.missingSuspenseWithCSRBailout &&
isBailoutToCSRError(err)
) {
throw err
}

if (debugOutput) {
const { dynamicUsageDescription, dynamicUsageStack } = (renderOpts as any)
.store
Expand Down
14 changes: 5 additions & 9 deletions packages/next/src/export/routes/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
NEXT_DATA_SUFFIX,
SERVER_PROPS_EXPORT_ERROR,
} from '../../lib/constants'
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
import { FileType, fileExists } from '../../lib/file-exists'
import { lazyRenderPagesPage } from '../../server/future/route-modules/pages/module.render'
Expand Down Expand Up @@ -105,10 +105,8 @@ export async function exportPages(
query,
renderOpts
)
} catch (err: any) {
if (!isBailoutCSRError(err)) {
throw err
}
} catch (err) {
if (!isBailoutToCSRError(err)) throw err
}
}

Expand Down Expand Up @@ -163,10 +161,8 @@ export async function exportPages(
{ ...query, amp: '1' },
renderOpts
)
} catch (err: any) {
if (!isBailoutCSRError(err)) {
throw err
}
} catch (err) {
if (!isBailoutToCSRError(err)) throw err
}

const ampHtml =
Expand Down
7 changes: 5 additions & 2 deletions packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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'
import { isDynamicUsageError } from './helpers/is-dynamic-usage-error'
import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr'

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

Expand Down Expand Up @@ -318,9 +319,11 @@ async function exportPageImpl(
// 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)
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n`
)
if (!isBailoutToCSRError(err)) {
console.error(isError(err) && err.stack ? err.stack : err)
}
}

return { error: true }
Expand Down
79 changes: 47 additions & 32 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-r
import { validateURL } from './validate-url'
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
import { handleAction } from './action-handler'
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { warn, error } from '../../build/output/log'
import { appendMutableCookies } from '../web/spec-extension/adapters/request-cookies'
import { createServerInsertedHTML } from './server-inserted-html'
Expand All @@ -77,8 +77,9 @@ import { setReferenceManifestsSingleton } from './action-encryption-utils'
import { createStaticRenderer } from './static/static-renderer'
import { MissingPostponeDataError } from './is-missing-postpone-error'
import { DetachedPromise } from '../../lib/detached-promise'
import { DYNAMIC_ERROR_CODE } from '../../client/components/hooks-server-context'
import { isDynamicServerError } from '../../client/components/hooks-server-context'
import { useFlightResponse } from './use-flight-response'
import { isStaticGenBailoutError } from '../../client/components/static-generation-bailout'

export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
Expand Down Expand Up @@ -306,6 +307,23 @@ async function generateFlight(
return new FlightRenderResult(flightReadableStream)
}

type RenderToStreamResult = {
stream: RenderResultResponse
err?: unknown
}

type RenderToStreamOptions = {
/**
* This option is used to indicate that the page should be rendered as
* if it was not found. When it's enabled, instead of rendering the
* page component, it renders the not-found segment.
*
*/
asNotFound: boolean
tree: LoaderTree
formState: any
}

/**
* Creates a resolver that eagerly generates a flight payload that is then
* resolved when the resolver is called.
Expand Down Expand Up @@ -804,23 +822,6 @@ async function renderToHTMLOrFlightImpl(
// response directly.
const onHeadersFinished = new DetachedPromise<void>()

type RenderToStreamResult = {
stream: RenderResultResponse
err?: Error
}

type RenderToStreamOptions = {
/**
* This option is used to indicate that the page should be rendered as
* if it was not found. When it's enabled, instead of rendering the
* page component, it renders the not-found segment.
*
*/
asNotFound: boolean
tree: LoaderTree
formState: any
}

const renderToStream = getTracer().wrap(
AppRenderSpan.getBodyResult,
{
Expand Down Expand Up @@ -979,29 +980,43 @@ async function renderToHTMLOrFlightImpl(
}

return { stream }
} catch (err: any) {
} catch (err) {
if (
err.code === 'NEXT_STATIC_GEN_BAILOUT' ||
err.message?.includes(
'https://nextjs.org/docs/advanced-features/static-html-export'
)
isStaticGenBailoutError(err) ||
(typeof err === 'object' &&
err !== null &&
'message' in err &&
typeof err.message === 'string' &&
err.message.includes(
'https://nextjs.org/docs/advanced-features/static-html-export'
))
) {
// Ensure that "next dev" prints the red error overlay
throw err
}

if (isStaticGeneration && err.digest === DYNAMIC_ERROR_CODE) {
// ensure that DynamicUsageErrors bubble up during static generation
// as this will indicate that the page needs to be dynamically rendered
// If this is a static generation error, we need to throw it so that it
// can be handled by the caller if we're in static generation mode.
if (isStaticGeneration && isDynamicServerError(err)) {
throw err
}

// True if this error was a bailout to client side rendering error.
const shouldBailoutToCSR = isBailoutCSRError(err)
// If a bailout made it to this point, it means it wasn't wrapped inside
// a suspense boundary.
const shouldBailoutToCSR = isBailoutToCSRError(err)
if (shouldBailoutToCSR) {
console.log()

if (renderOpts.experimental.missingSuspenseWithCSRBailout) {
error(
`${err.reason} should be wrapped in a suspense boundary at page "${pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout`
)

throw err
}

warn(
`Entire page ${pagePath} deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`,
pagePath
`Entire page "${pagePath}" deopted into client-side rendering due to "${err.reason}". Read more: https://nextjs.org/docs/messages/deopted-into-client-rendering`
)
}

Expand Down Expand Up @@ -1212,7 +1227,7 @@ async function renderToHTMLOrFlightImpl(
renderOpts.experimental.ppr &&
staticGenerationStore.postponeWasTriggered &&
!metadata.postponed &&
(!response.err || !isBailoutCSRError(response.err))
(!response.err || !isBailoutToCSRError(response.err))
) {
// 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
Expand Down
Loading

0 comments on commit dda1870

Please sign in to comment.