Skip to content

Commit c52cb5a

Browse files
feedthejimbalazsorban44wyattjoh
authored
feat(app): add experimental.missingSuspenseWithCSRBailout (vercel#57642)
### What? This PR adds a new flag called `experimental.missingSuspenseWithCSRBailout`. ### Why? Via this PR we can break a build when calling `useSearchParams` without wrapping it in a suspense boundary. If no suspense boundaries are present, Next.js must avoid doing SSR and defer the entire page's rendering to the client. This is not a great default. Instead, we will now break the build so that you are forced to add a boundary. ### How? Add an experimental flag. If a `BailoutToCSRError` error is thrown and this flag is enabled, the build should fail and log an error, instead of showing a warning and bail the entire page to client-side rendering. Closes NEXT-1770 --------- Co-authored-by: Balázs Orbán <[email protected]> Co-authored-by: Wyatt Johnson <[email protected]>
1 parent 8aced5b commit c52cb5a

30 files changed

+471
-387
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
title: Missing Suspense with CSR Bailout
3+
---
4+
5+
#### Why This Error Occurred
6+
7+
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.
8+
9+
#### Possible Ways to Fix It
10+
11+
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.
12+
13+
### Useful Links
14+
15+
- [`useSearchParams`](https://nextjs.org/docs/app/api-reference/functions/use-search-params)
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { throwWithNoSSR } from '../../shared/lib/lazy-dynamic/no-ssr-error'
1+
import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
22
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
33

4-
export function bailoutToClientRendering(): void | never {
4+
export function bailoutToClientRendering(reason: string): void | never {
55
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
66

7-
if (staticGenerationStore?.forceStatic) {
8-
return
9-
}
7+
if (staticGenerationStore?.forceStatic) return
108

11-
if (staticGenerationStore?.isStaticGeneration) {
12-
throwWithNoSSR()
13-
}
9+
if (staticGenerationStore?.isStaticGeneration)
10+
throw new BailoutToCSRError(reason)
1411
}

packages/next/src/client/components/navigation.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function useSearchParams(): ReadonlyURLSearchParams {
9292
const { bailoutToClientRendering } =
9393
require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering')
9494
// TODO-APP: handle dynamic = 'force-static' here and on the client
95-
bailoutToClientRendering()
95+
bailoutToClientRendering('useSearchParams()')
9696
}
9797

9898
return readonlySearchParams

packages/next/src/client/on-recoverable-error.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { isBailoutCSRError } from '../shared/lib/lazy-dynamic/no-ssr-error'
1+
import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr'
22

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

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

1818
defaultOnRecoverableError(err)
1919
}
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { DYNAMIC_ERROR_CODE } from '../../client/components/hooks-server-context'
22
import { isNotFoundError } from '../../client/components/not-found'
33
import { isRedirectError } from '../../client/components/redirect'
4-
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
54

65
export const isDynamicUsageError = (err: any) =>
76
err.digest === DYNAMIC_ERROR_CODE ||
87
isNotFoundError(err) ||
9-
isBailoutCSRError(err) ||
108
isRedirectError(err)

packages/next/src/export/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,11 @@ export async function exportAppImpl(
506506
: {}),
507507
strictNextHead: !!nextConfig.experimental.strictNextHead,
508508
deploymentId: nextConfig.experimental.deploymentId,
509-
experimental: { ppr: nextConfig.experimental.ppr === true },
509+
experimental: {
510+
ppr: nextConfig.experimental.ppr === true,
511+
missingSuspenseWithCSRBailout:
512+
nextConfig.experimental.missingSuspenseWithCSRBailout,
513+
},
510514
}
511515

512516
const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig

packages/next/src/export/routes/pages.ts

+5-9
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
NEXT_DATA_SUFFIX,
1616
SERVER_PROPS_EXPORT_ERROR,
1717
} from '../../lib/constants'
18-
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
18+
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
1919
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
2020
import { FileType, fileExists } from '../../lib/file-exists'
2121
import { lazyRenderPagesPage } from '../../server/future/route-modules/pages/module.render'
@@ -105,10 +105,8 @@ export async function exportPages(
105105
query,
106106
renderOpts
107107
)
108-
} catch (err: any) {
109-
if (!isBailoutCSRError(err)) {
110-
throw err
111-
}
108+
} catch (err) {
109+
if (!isBailoutToCSRError(err)) throw err
112110
}
113111
}
114112

@@ -163,10 +161,8 @@ export async function exportPages(
163161
{ ...query, amp: '1' },
164162
renderOpts
165163
)
166-
} catch (err: any) {
167-
if (!isBailoutCSRError(err)) {
168-
throw err
169-
}
164+
} catch (err) {
165+
if (!isBailoutToCSRError(err)) throw err
170166
}
171167

172168
const ampHtml =

packages/next/src/export/worker.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { createIncrementalCache } from './helpers/create-incremental-cache'
3535
import { isPostpone } from '../server/lib/router-utils/is-postpone'
3636
import { isMissingPostponeDataError } from '../server/app-render/is-missing-postpone-error'
3737
import { isDynamicUsageError } from './helpers/is-dynamic-usage-error'
38+
import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr'
3839

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

@@ -318,9 +319,11 @@ async function exportPageImpl(
318319
// if this is a postpone error, it's logged elsewhere, so no need to log it again here
319320
if (!isMissingPostponeDataError(err)) {
320321
console.error(
321-
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n` +
322-
(isError(err) && err.stack ? err.stack : err)
322+
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n`
323323
)
324+
if (!isBailoutToCSRError(err)) {
325+
console.error(isError(err) && err.stack ? err.stack : err)
326+
}
324327
}
325328

326329
return { error: true }

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

+13-6
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-r
6363
import { validateURL } from './validate-url'
6464
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
6565
import { handleAction } from './action-handler'
66-
import { isBailoutCSRError } from '../../shared/lib/lazy-dynamic/no-ssr-error'
66+
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
6767
import { warn, error } from '../../build/output/log'
6868
import { appendMutableCookies } from '../web/spec-extension/adapters/request-cookies'
6969
import { createServerInsertedHTML } from './server-inserted-html'
@@ -996,12 +996,19 @@ async function renderToHTMLOrFlightImpl(
996996
throw err
997997
}
998998

999-
// True if this error was a bailout to client side rendering error.
1000-
const shouldBailoutToCSR = isBailoutCSRError(err)
999+
/** True if this error was a bailout to client side rendering error. */
1000+
const shouldBailoutToCSR = isBailoutToCSRError(err)
10011001
if (shouldBailoutToCSR) {
1002+
console.log()
1003+
1004+
if (renderOpts.experimental.missingSuspenseWithCSRBailout) {
1005+
error(
1006+
`${err.message} should be wrapped in a suspense boundary at page "${pagePath}". https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout`
1007+
)
1008+
throw err
1009+
}
10021010
warn(
1003-
`Entire page ${pagePath} deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`,
1004-
pagePath
1011+
`Entire page "${pagePath}" deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`
10051012
)
10061013
}
10071014

@@ -1212,7 +1219,7 @@ async function renderToHTMLOrFlightImpl(
12121219
renderOpts.experimental.ppr &&
12131220
staticGenerationStore.postponeWasTriggered &&
12141221
!metadata.postponed &&
1215-
(!response.err || !isBailoutCSRError(response.err))
1222+
(!response.err || !isBailoutToCSRError(response.err))
12161223
) {
12171224
// a call to postpone was made but was caught and not detected by Next.js. We should fail the build immediately
12181225
// as we won't be able to generate the static part

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

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { formatServerError } from '../../lib/format-server-error'
33
import { SpanStatusCode, getTracer } from '../lib/trace/tracer'
44
import { isAbortError } from '../pipe-readable'
55
import { isDynamicUsageError } from '../../export/helpers/is-dynamic-usage-error'
6+
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
67

78
export type ErrorHandler = (err: any) => string | undefined
89

@@ -34,11 +35,12 @@ export function createErrorHandler({
3435
return (err) => {
3536
if (allCapturedErrors) allCapturedErrors.push(err)
3637

38+
// A formatted error is already logged for this type of error
39+
if (isBailoutToCSRError(err)) return
40+
3741
// These errors are expected. We return the digest
3842
// so that they can be properly handled.
39-
if (isDynamicUsageError(err)) {
40-
return err.digest
41-
}
43+
if (isDynamicUsageError(err)) return err.digest
4244

4345
// If the response was closed, we don't need to log the error.
4446
if (isAbortError(err)) return

packages/next/src/server/app-render/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export interface RenderOptsPartial {
142142
}
143143
params?: ParsedUrlQuery
144144
isPrefetch?: boolean
145-
experimental: { ppr: boolean }
145+
experimental: { ppr: boolean; missingSuspenseWithCSRBailout?: boolean }
146146
postponed?: string
147147
}
148148

packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type StaticGenerationContext = {
1818
isDraftMode?: boolean
1919
isServerAction?: boolean
2020
waitUntil?: Promise<any>
21-
experimental: { ppr: boolean }
21+
experimental: { ppr: boolean; missingSuspenseWithCSRBailout?: boolean }
2222

2323
/**
2424
* A hack around accessing the store value outside the context of the

packages/next/src/server/config-schema.ts

+1
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
369369
staticWorkerRequestDeduping: z.boolean().optional(),
370370
useWasmBinary: z.boolean().optional(),
371371
useLightningcss: z.boolean().optional(),
372+
missingSuspenseWithCSRBailout: z.boolean().optional(),
372373
})
373374
.optional(),
374375
exportPathMap: z

packages/next/src/server/config-shared.ts

+11
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,16 @@ export interface ExperimentalConfig {
351351
* Use lightningcss instead of swc_css
352352
*/
353353
useLightningcss?: boolean
354+
355+
/**
356+
* Certain methods calls like `useSearchParams()` can bail out of server-side rendering of **entire** pages to client-side rendering,
357+
* if they are not wrapped in a suspense boundary.
358+
*
359+
* When this flag is set to `true`, Next.js will break the build instead of warning, to force the developer to add a suspense boundary above the method call.
360+
*
361+
* @default false
362+
*/
363+
missingSuspenseWithCSRBailout?: boolean
354364
}
355365

356366
export type ExportPathMap = {
@@ -811,6 +821,7 @@ export const defaultConfig: NextConfig = {
811821
? true
812822
: false,
813823
webpackBuildWorker: undefined,
824+
missingSuspenseWithCSRBailout: false,
814825
},
815826
}
816827

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This has to be a shared module which is shared between client component error boundary and dynamic component
2+
3+
const BAILOUT_TO_CSR = 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
4+
5+
/** An error that should be thrown when we want to bail out to client-side rendering. */
6+
export class BailoutToCSRError extends Error {
7+
digest: typeof BAILOUT_TO_CSR = BAILOUT_TO_CSR
8+
}
9+
10+
/** Checks if a passed argument is an error that is thrown if we want to bail out to client-side rendering. */
11+
export function isBailoutToCSRError(err: unknown): err is BailoutToCSRError {
12+
if (typeof err !== 'object' || err === null) return false
13+
return 'digest' in err && err.digest === BAILOUT_TO_CSR
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client'
2+
3+
import type { ReactElement } from 'react'
4+
import { BailoutToCSRError } from './bailout-to-csr'
5+
6+
interface BailoutToCSRProps {
7+
reason: string
8+
children: ReactElement
9+
}
10+
11+
/**
12+
* If rendered on the server, this component throws an error
13+
* to signal Next.js that it should bail out to client-side rendering instead.
14+
*/
15+
export function BailoutToCSR({ reason, children }: BailoutToCSRProps) {
16+
if (typeof window === 'undefined') {
17+
throw new BailoutToCSRError(reason)
18+
}
19+
20+
return children
21+
}

packages/next/src/shared/lib/lazy-dynamic/dynamic-no-ssr.tsx

-14
This file was deleted.

packages/next/src/shared/lib/lazy-dynamic/loadable.tsx

+24-22
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,45 @@
1-
import { Suspense, lazy, Fragment } from 'react'
2-
import { NoSSR } from './dynamic-no-ssr'
1+
import { Suspense, lazy } from 'react'
2+
import { BailoutToCSR } from './dynamic-bailout-to-csr'
33
import type { ComponentModule } from './types'
44

55
// Normalize loader to return the module as form { default: Component } for `React.lazy`.
66
// Also for backward compatible since next/dynamic allows to resolve a component directly with loader
77
// Client component reference proxy need to be converted to a module.
88
function convertModule<P>(mod: React.ComponentType<P> | ComponentModule<P>) {
9-
return { default: (mod as ComponentModule<P>)?.default || mod }
9+
return { default: (mod as ComponentModule<P>)?.default ?? mod }
1010
}
1111

12-
function Loadable(options: any) {
13-
const opts = {
14-
loader: null,
15-
loading: null,
16-
ssr: true,
17-
...options,
18-
}
12+
const defaultOptions = {
13+
loader: () => Promise.resolve(convertModule(() => null)),
14+
loading: null,
15+
ssr: true,
16+
}
1917

20-
const loader = () =>
21-
opts.loader != null
22-
? opts.loader().then(convertModule)
23-
: Promise.resolve(convertModule(() => null))
18+
interface LoadableOptions {
19+
loader?: () => Promise<React.ComponentType<any> | ComponentModule<any>>
20+
loading?: React.ComponentType<any> | null
21+
ssr?: boolean
22+
}
2423

25-
const Lazy = lazy(loader)
24+
function Loadable(options: LoadableOptions) {
25+
const opts = { ...defaultOptions, ...options }
26+
const Lazy = lazy(() => opts.loader().then(convertModule))
2627
const Loading = opts.loading
27-
const Wrap = opts.ssr ? Fragment : NoSSR
2828

2929
function LoadableComponent(props: any) {
3030
const fallbackElement = Loading ? (
3131
<Loading isLoading={true} pastDelay={true} error={null} />
3232
) : null
3333

34-
return (
35-
<Suspense fallback={fallbackElement}>
36-
<Wrap>
37-
<Lazy {...props} />
38-
</Wrap>
39-
</Suspense>
34+
const children = opts.ssr ? (
35+
<Lazy {...props} />
36+
) : (
37+
<BailoutToCSR reason="next/dynamic">
38+
<Lazy {...props} />
39+
</BailoutToCSR>
4040
)
41+
42+
return <Suspense fallback={fallbackElement}>{children}</Suspense>
4143
}
4244

4345
LoadableComponent.displayName = 'LoadableComponent'

packages/next/src/shared/lib/lazy-dynamic/no-ssr-error.ts

-13
This file was deleted.

0 commit comments

Comments
 (0)