Reapply "feat(app-router): introduce experimental.missingSuspenseWithCSRBailout 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)
This commit is contained in:
Wyatt Johnson 2024-01-17 04:33:45 -07:00 committed by GitHub
parent 7f3d9099c2
commit dda1870501
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 625 additions and 430 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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 }

View file

@ -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 =
@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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',
@ -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

View file

@ -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'
@ -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
}
}
@ -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 =

View file

@ -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')
@ -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 }

View file

@ -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'
@ -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]
@ -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.
@ -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,
{
@ -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`
)
}
@ -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

View file

@ -36,9 +36,7 @@ export function createErrorHandler({
// These errors are expected. We return the digest
// so that they can be properly handled.
if (isDynamicUsageError(err)) {
return err.digest
}
if (isDynamicUsageError(err)) return err.digest
// If the response was closed, we don't need to log the error.
if (isAbortError(err)) return

View file

@ -142,7 +142,7 @@ export interface RenderOptsPartial {
}
params?: ParsedUrlQuery
isPrefetch?: boolean
experimental: { ppr: boolean }
experimental: { ppr: boolean; missingSuspenseWithCSRBailout: boolean }
postponed?: string
}

View file

@ -18,7 +18,7 @@ export type StaticGenerationContext = {
isDraftMode?: boolean
isServerAction?: boolean
waitUntil?: Promise<any>
experimental: { ppr: boolean }
experimental: { ppr: boolean; missingSuspenseWithCSRBailout?: boolean }
/**
* A hack around accessing the store value outside the context of the

View file

@ -258,7 +258,7 @@ type BaseRenderOpts = {
appDirDevErrorLogger?: (err: any) => Promise<void>
strictNextHead: boolean
isExperimentalCompile?: boolean
experimental: { ppr: boolean }
experimental: { ppr: boolean; missingSuspenseWithCSRBailout: boolean }
}
export interface BaseRequestHandler {
@ -548,6 +548,8 @@ export default abstract class Server<ServerOptions extends Options = Options> {
ppr:
this.enabledDirectories.app &&
this.nextConfig.experimental.ppr === true,
missingSuspenseWithCSRBailout:
this.nextConfig.experimental.missingSuspenseWithCSRBailout === true,
},
}

View file

@ -370,6 +370,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
staticWorkerRequestDeduping: z.boolean().optional(),
useWasmBinary: z.boolean().optional(),
useLightningcss: z.boolean().optional(),
missingSuspenseWithCSRBailout: z.boolean().optional(),
})
.optional(),
exportPathMap: z

View file

@ -379,6 +379,16 @@ export interface ExperimentalConfig {
* Use lightningcss instead of swc_css
*/
useLightningcss?: boolean
/**
* Certain methods calls like `useSearchParams()` can bail out of server-side rendering of **entire** pages to client-side rendering,
* if they are not wrapped in a suspense boundary.
*
* 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.
*
* @default false
*/
missingSuspenseWithCSRBailout?: boolean
}
export type ExportPathMap = {
@ -840,6 +850,7 @@ export const defaultConfig: NextConfig = {
? true
: false,
webpackBuildWorker: undefined,
missingSuspenseWithCSRBailout: false,
},
}

View file

@ -0,0 +1,20 @@
// This has to be a shared module which is shared between client component error boundary and dynamic component
const BAILOUT_TO_CSR = 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
/** An error that should be thrown when we want to bail out to client-side rendering. */
export class BailoutToCSRError extends Error {
public readonly digest = BAILOUT_TO_CSR
constructor(public readonly reason: string) {
super(`Bail out to client-side rendering: ${reason}`)
}
}
/** Checks if a passed argument is an error that is thrown if we want to bail out to client-side rendering. */
export function isBailoutToCSRError(err: unknown): err is BailoutToCSRError {
if (typeof err !== 'object' || err === null || !('digest' in err)) {
return false
}
return err.digest === BAILOUT_TO_CSR
}

View file

@ -0,0 +1,21 @@
'use client'
import type { ReactElement } from 'react'
import { BailoutToCSRError } from './bailout-to-csr'
interface BailoutToCSRProps {
reason: string
children: ReactElement
}
/**
* If rendered on the server, this component throws an error
* to signal Next.js that it should bail out to client-side rendering instead.
*/
export function BailoutToCSR({ reason, children }: BailoutToCSRProps) {
if (typeof window === 'undefined') {
throw new BailoutToCSRError(reason)
}
return children
}

View file

@ -1,14 +0,0 @@
'use client'
import type React from 'react'
import { throwWithNoSSR } from './no-ssr-error'
type Child = React.ReactElement<any, any>
export function NoSSR({ children }: { children: Child }): Child {
if (typeof window === 'undefined') {
throwWithNoSSR()
}
return children
}

View file

@ -1,43 +1,45 @@
import { Suspense, lazy, Fragment } from 'react'
import { NoSSR } from './dynamic-no-ssr'
import { Suspense, lazy } from 'react'
import { BailoutToCSR } from './dynamic-bailout-to-csr'
import type { ComponentModule } from './types'
// Normalize loader to return the module as form { default: Component } for `React.lazy`.
// Also for backward compatible since next/dynamic allows to resolve a component directly with loader
// Client component reference proxy need to be converted to a module.
function convertModule<P>(mod: React.ComponentType<P> | ComponentModule<P>) {
return { default: (mod as ComponentModule<P>)?.default || mod }
return { default: (mod as ComponentModule<P>)?.default ?? mod }
}
function Loadable(options: any) {
const opts = {
loader: null,
loading: null,
ssr: true,
...options,
}
const defaultOptions = {
loader: () => Promise.resolve(convertModule(() => null)),
loading: null,
ssr: true,
}
const loader = () =>
opts.loader != null
? opts.loader().then(convertModule)
: Promise.resolve(convertModule(() => null))
interface LoadableOptions {
loader?: () => Promise<React.ComponentType<any> | ComponentModule<any>>
loading?: React.ComponentType<any> | null
ssr?: boolean
}
const Lazy = lazy(loader)
function Loadable(options: LoadableOptions) {
const opts = { ...defaultOptions, ...options }
const Lazy = lazy(() => opts.loader().then(convertModule))
const Loading = opts.loading
const Wrap = opts.ssr ? Fragment : NoSSR
function LoadableComponent(props: any) {
const fallbackElement = Loading ? (
<Loading isLoading={true} pastDelay={true} error={null} />
) : null
return (
<Suspense fallback={fallbackElement}>
<Wrap>
<Lazy {...props} />
</Wrap>
</Suspense>
const children = opts.ssr ? (
<Lazy {...props} />
) : (
<BailoutToCSR reason="next/dynamic">
<Lazy {...props} />
</BailoutToCSR>
)
return <Suspense fallback={fallbackElement}>{children}</Suspense>
}
LoadableComponent.displayName = 'LoadableComponent'

View file

@ -1,13 +0,0 @@
// This has to be a shared module which is shared between client component error boundary and dynamic component
export const NEXT_DYNAMIC_NO_SSR_CODE = 'NEXT_DYNAMIC_NO_SSR_CODE'
export function throwWithNoSSR() {
const error = new Error(NEXT_DYNAMIC_NO_SSR_CODE)
;(error as any).digest = NEXT_DYNAMIC_NO_SSR_CODE
throw error
}
export function isBailoutCSRError(err: any) {
return err?.digest === NEXT_DYNAMIC_NO_SSR_CODE
}

View file

@ -494,235 +494,231 @@ createNextDescribe(
)
})
expect(files.sort()).toEqual(
expect(files.sort()).toMatchInlineSnapshot(`
[
'page.js',
'index.rsc',
'index.html',
'blog/seb.rsc',
'blog/tim.rsc',
'_not-found.js',
'blog/seb.html',
'blog/tim.html',
'isr-error-handling.rsc',
'_not-found.rsc',
'_not-found.html',
'blog/styfle.rsc',
'force-cache.rsc',
'blog/styfle.html',
'force-cache.html',
'isr-error-handling/page.js',
'ssg-draft-mode.rsc',
'ssr-forced/page.js',
'articles/works.rsc',
'force-cache/page.js',
'force-cache/large-data/page.js',
'force-cache/large-data/page_client-reference-manifest.js',
'ssg-draft-mode.html',
'articles/works.html',
'no-store/static.rsc',
'(new)/custom/page.js',
'force-static/page.js',
'response-url/page.js',
'no-store/static.html',
'blog/[author]/page.js',
'default-cache/page.js',
'fetch-no-cache/page.js',
'force-no-store/page.js',
'force-static-fetch-no-store.html',
'force-static-fetch-no-store.rsc',
'force-static-fetch-no-store/page.js',
'force-static-fetch-no-store/page_client-reference-manifest.js',
'force-static/first.rsc',
'api/draft-mode/route.js',
'api/large-data/route.js',
'blog/tim/first-post.rsc',
'force-static/first.html',
'force-static/second.rsc',
'ssg-draft-mode/test.rsc',
'isr-error-handling.html',
'articles/[slug]/page.js',
'no-store/static/page.js',
'blog/seb/second-post.rsc',
'blog/tim/first-post.html',
'force-static/second.html',
'ssg-draft-mode/test.html',
'no-store/dynamic/page.js',
'blog/seb/second-post.html',
'ssg-draft-mode/test-2.rsc',
'blog/styfle/first-post.rsc',
'dynamic-error/[id]/page.js',
'ssg-draft-mode/test-2.html',
'blog/styfle/first-post.html',
'blog/styfle/second-post.rsc',
'force-static/[slug]/page.js',
'hooks/use-pathname/slug.rsc',
'route-handler/post/route.js',
'blog/[author]/[slug]/page.js',
'blog/styfle/second-post.html',
'hooks/use-pathname/slug.html',
'flight/[slug]/[slug2]/page.js',
'variable-revalidate/cookie.rsc',
'ssr-auto/cache-no-store/page.js',
'variable-revalidate/cookie.html',
'api/revalidate-tag-edge/route.js',
'api/revalidate-tag-node/route.js',
'variable-revalidate/encoding.rsc',
'api/revalidate-path-edge/route.js',
'api/revalidate-path-node/route.js',
'gen-params-dynamic/[slug]/page.js',
'hooks/use-pathname/[slug]/page.js',
'page_client-reference-manifest.js',
'react-fetch-deduping-edge/page.js',
'react-fetch-deduping-node/page.js',
'variable-revalidate/encoding.html',
'variable-revalidate/cookie/page.js',
'ssg-draft-mode/[[...route]]/page.js',
'variable-revalidate/post-method.rsc',
'stale-cache-serving/app-page/page.js',
'dynamic-no-gen-params/[slug]/page.js',
'static-to-dynamic-error/[id]/page.js',
'variable-revalidate/encoding/page.js',
'variable-revalidate/no-store/page.js',
'variable-revalidate/post-method.html',
'variable-revalidate/revalidate-3.rsc',
'gen-params-dynamic-revalidate/one.rsc',
'route-handler/revalidate-360/route.js',
'route-handler/static-cookies/route.js',
'variable-revalidate-edge/body/page.js',
'variable-revalidate/authorization.rsc',
'variable-revalidate/revalidate-3.html',
'force-dynamic-prerender/[slug]/page.js',
'gen-params-dynamic-revalidate/one.html',
'ssr-auto/fetch-revalidate-zero/page.js',
'variable-revalidate/authorization.html',
'_not-found_client-reference-manifest.js',
'force-dynamic-no-prerender/[id]/page.js',
'variable-revalidate/post-method/page.js',
'variable-revalidate/status-code/page.js',
'dynamic-no-gen-params-ssr/[slug]/page.js',
'hooks/use-search-params/force-static.rsc',
'partial-gen-params/[lang]/[slug]/page.js',
'variable-revalidate/headers-instance.rsc',
'variable-revalidate/revalidate-3/page.js',
'stale-cache-serving-edge/app-page/page.js',
'hooks/use-search-params/force-static.html',
'hooks/use-search-params/with-suspense.rsc',
'route-handler/revalidate-360-isr/route.js',
'variable-revalidate-edge/encoding/page.js',
'variable-revalidate-edge/no-store/page.js',
'variable-revalidate/authorization/page.js',
'variable-revalidate/headers-instance.html',
'stale-cache-serving/route-handler/route.js',
'hooks/use-search-params/with-suspense.html',
'route-handler-edge/revalidate-360/route.js',
'variable-revalidate/revalidate-360-isr.rsc',
'variable-revalidate/revalidate-360/page.js',
'static-to-dynamic-error-forced/[id]/page.js',
'variable-config-revalidate/revalidate-3.rsc',
'variable-revalidate/revalidate-360-isr.html',
'isr-error-handling/page_client-reference-manifest.js',
'gen-params-dynamic-revalidate/[slug]/page.js',
'hooks/use-search-params/force-static/page.js',
'ssr-forced/page_client-reference-manifest.js',
'variable-config-revalidate/revalidate-3.html',
'variable-revalidate-edge/post-method/page.js',
'variable-revalidate/headers-instance/page.js',
'force-cache/page_client-reference-manifest.js',
'hooks/use-search-params/with-suspense/page.js',
'variable-revalidate-edge/revalidate-3/page.js',
'(new)/custom/page_client-reference-manifest.js',
'force-static/page_client-reference-manifest.js',
'response-url/page_client-reference-manifest.js',
'variable-revalidate/revalidate-360-isr/page.js',
'stale-cache-serving-edge/route-handler/route.js',
'blog/[author]/page_client-reference-manifest.js',
'default-cache/page_client-reference-manifest.js',
'variable-config-revalidate/revalidate-3/page.js',
'variable-revalidate/post-method-request/page.js',
'fetch-no-cache/page_client-reference-manifest.js',
'force-dynamic-catch-all/[slug]/[[...id]]/page.js',
'force-no-store/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/en/RAND.rsc',
'partial-gen-params-no-additional-lang/fr/RAND.rsc',
'partial-gen-params-no-additional-slug/en/RAND.rsc',
'partial-gen-params-no-additional-slug/fr/RAND.rsc',
'articles/[slug]/page_client-reference-manifest.js',
'no-store/static/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/en/RAND.html',
'partial-gen-params-no-additional-lang/en/first.rsc',
'partial-gen-params-no-additional-lang/fr/RAND.html',
'partial-gen-params-no-additional-lang/fr/first.rsc',
'partial-gen-params-no-additional-slug/en/RAND.html',
'partial-gen-params-no-additional-slug/en/first.rsc',
'partial-gen-params-no-additional-slug/fr/RAND.html',
'partial-gen-params-no-additional-slug/fr/first.rsc',
'no-store/dynamic/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/en/first.html',
'partial-gen-params-no-additional-lang/en/second.rsc',
'partial-gen-params-no-additional-lang/fr/first.html',
'partial-gen-params-no-additional-lang/fr/second.rsc',
'partial-gen-params-no-additional-slug/en/first.html',
'partial-gen-params-no-additional-slug/en/second.rsc',
'partial-gen-params-no-additional-slug/fr/first.html',
'partial-gen-params-no-additional-slug/fr/second.rsc',
'dynamic-error/[id]/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/en/second.html',
'partial-gen-params-no-additional-lang/fr/second.html',
'partial-gen-params-no-additional-slug/en/second.html',
'partial-gen-params-no-additional-slug/fr/second.html',
'variable-revalidate-edge/post-method-request/page.js',
'force-static/[slug]/page_client-reference-manifest.js',
'blog/[author]/[slug]/page_client-reference-manifest.js',
'flight/[slug]/[slug2]/page_client-reference-manifest.js',
'hooks/use-search-params/static-bailout.html',
'hooks/use-search-params/static-bailout.rsc',
'hooks/use-search-params/static-bailout/page.js',
'hooks/use-search-params/static-bailout/page_client-reference-manifest.js',
'ssr-auto/cache-no-store/page_client-reference-manifest.js',
'gen-params-dynamic/[slug]/page_client-reference-manifest.js',
'hooks/use-pathname/[slug]/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/[lang]/[slug]/page.js',
'partial-gen-params-no-additional-slug/[lang]/[slug]/page.js',
'react-fetch-deduping-edge/page_client-reference-manifest.js',
'react-fetch-deduping-node/page_client-reference-manifest.js',
'variable-revalidate/cookie/page_client-reference-manifest.js',
'ssg-draft-mode/[[...route]]/page_client-reference-manifest.js',
'stale-cache-serving/app-page/page_client-reference-manifest.js',
'dynamic-no-gen-params/[slug]/page_client-reference-manifest.js',
'static-to-dynamic-error/[id]/page_client-reference-manifest.js',
'variable-revalidate/encoding/page_client-reference-manifest.js',
'variable-revalidate/no-store/page_client-reference-manifest.js',
'variable-revalidate-edge/body/page_client-reference-manifest.js',
'force-dynamic-prerender/[slug]/page_client-reference-manifest.js',
'ssr-auto/fetch-revalidate-zero/page_client-reference-manifest.js',
'force-dynamic-no-prerender/[id]/page_client-reference-manifest.js',
'variable-revalidate/post-method/page_client-reference-manifest.js',
'variable-revalidate/status-code/page_client-reference-manifest.js',
'dynamic-no-gen-params-ssr/[slug]/page_client-reference-manifest.js',
'partial-gen-params/[lang]/[slug]/page_client-reference-manifest.js',
'variable-revalidate/revalidate-3/page_client-reference-manifest.js',
'stale-cache-serving-edge/app-page/page_client-reference-manifest.js',
'variable-revalidate-edge/encoding/page_client-reference-manifest.js',
'variable-revalidate-edge/no-store/page_client-reference-manifest.js',
'variable-revalidate/authorization/page_client-reference-manifest.js',
'variable-revalidate/revalidate-360/page_client-reference-manifest.js',
'static-to-dynamic-error-forced/[id]/page_client-reference-manifest.js',
'gen-params-dynamic-revalidate/[slug]/page_client-reference-manifest.js',
'hooks/use-search-params/force-static/page_client-reference-manifest.js',
'variable-revalidate-edge/post-method/page_client-reference-manifest.js',
'variable-revalidate/headers-instance/page_client-reference-manifest.js',
'hooks/use-search-params/with-suspense/page_client-reference-manifest.js',
'variable-revalidate-edge/revalidate-3/page_client-reference-manifest.js',
'variable-revalidate/revalidate-360-isr/page_client-reference-manifest.js',
'variable-config-revalidate/revalidate-3/page_client-reference-manifest.js',
'variable-revalidate/post-method-request/page_client-reference-manifest.js',
'force-dynamic-catch-all/[slug]/[[...id]]/page_client-reference-manifest.js',
'variable-revalidate-edge/post-method-request/page_client-reference-manifest.js',
'partial-gen-params-no-additional-lang/[lang]/[slug]/page_client-reference-manifest.js',
'partial-gen-params-no-additional-slug/[lang]/[slug]/page_client-reference-manifest.js',
].sort()
)
"(new)/custom/page.js",
"(new)/custom/page_client-reference-manifest.js",
"_not-found.html",
"_not-found.js",
"_not-found.rsc",
"_not-found_client-reference-manifest.js",
"api/draft-mode/route.js",
"api/large-data/route.js",
"api/revalidate-path-edge/route.js",
"api/revalidate-path-node/route.js",
"api/revalidate-tag-edge/route.js",
"api/revalidate-tag-node/route.js",
"articles/[slug]/page.js",
"articles/[slug]/page_client-reference-manifest.js",
"articles/works.html",
"articles/works.rsc",
"blog/[author]/[slug]/page.js",
"blog/[author]/[slug]/page_client-reference-manifest.js",
"blog/[author]/page.js",
"blog/[author]/page_client-reference-manifest.js",
"blog/seb.html",
"blog/seb.rsc",
"blog/seb/second-post.html",
"blog/seb/second-post.rsc",
"blog/styfle.html",
"blog/styfle.rsc",
"blog/styfle/first-post.html",
"blog/styfle/first-post.rsc",
"blog/styfle/second-post.html",
"blog/styfle/second-post.rsc",
"blog/tim.html",
"blog/tim.rsc",
"blog/tim/first-post.html",
"blog/tim/first-post.rsc",
"default-cache/page.js",
"default-cache/page_client-reference-manifest.js",
"dynamic-error/[id]/page.js",
"dynamic-error/[id]/page_client-reference-manifest.js",
"dynamic-no-gen-params-ssr/[slug]/page.js",
"dynamic-no-gen-params-ssr/[slug]/page_client-reference-manifest.js",
"dynamic-no-gen-params/[slug]/page.js",
"dynamic-no-gen-params/[slug]/page_client-reference-manifest.js",
"fetch-no-cache/page.js",
"fetch-no-cache/page_client-reference-manifest.js",
"flight/[slug]/[slug2]/page.js",
"flight/[slug]/[slug2]/page_client-reference-manifest.js",
"force-cache.html",
"force-cache.rsc",
"force-cache/large-data/page.js",
"force-cache/large-data/page_client-reference-manifest.js",
"force-cache/page.js",
"force-cache/page_client-reference-manifest.js",
"force-dynamic-catch-all/[slug]/[[...id]]/page.js",
"force-dynamic-catch-all/[slug]/[[...id]]/page_client-reference-manifest.js",
"force-dynamic-no-prerender/[id]/page.js",
"force-dynamic-no-prerender/[id]/page_client-reference-manifest.js",
"force-dynamic-prerender/[slug]/page.js",
"force-dynamic-prerender/[slug]/page_client-reference-manifest.js",
"force-no-store/page.js",
"force-no-store/page_client-reference-manifest.js",
"force-static-fetch-no-store.html",
"force-static-fetch-no-store.rsc",
"force-static-fetch-no-store/page.js",
"force-static-fetch-no-store/page_client-reference-manifest.js",
"force-static/[slug]/page.js",
"force-static/[slug]/page_client-reference-manifest.js",
"force-static/first.html",
"force-static/first.rsc",
"force-static/page.js",
"force-static/page_client-reference-manifest.js",
"force-static/second.html",
"force-static/second.rsc",
"gen-params-dynamic-revalidate/[slug]/page.js",
"gen-params-dynamic-revalidate/[slug]/page_client-reference-manifest.js",
"gen-params-dynamic-revalidate/one.html",
"gen-params-dynamic-revalidate/one.rsc",
"gen-params-dynamic/[slug]/page.js",
"gen-params-dynamic/[slug]/page_client-reference-manifest.js",
"hooks/use-pathname/[slug]/page.js",
"hooks/use-pathname/[slug]/page_client-reference-manifest.js",
"hooks/use-pathname/slug.html",
"hooks/use-pathname/slug.rsc",
"hooks/use-search-params/force-static.html",
"hooks/use-search-params/force-static.rsc",
"hooks/use-search-params/force-static/page.js",
"hooks/use-search-params/force-static/page_client-reference-manifest.js",
"hooks/use-search-params/with-suspense.html",
"hooks/use-search-params/with-suspense.rsc",
"hooks/use-search-params/with-suspense/page.js",
"hooks/use-search-params/with-suspense/page_client-reference-manifest.js",
"index.html",
"index.rsc",
"isr-error-handling.html",
"isr-error-handling.rsc",
"isr-error-handling/page.js",
"isr-error-handling/page_client-reference-manifest.js",
"no-store/dynamic/page.js",
"no-store/dynamic/page_client-reference-manifest.js",
"no-store/static.html",
"no-store/static.rsc",
"no-store/static/page.js",
"no-store/static/page_client-reference-manifest.js",
"page.js",
"page_client-reference-manifest.js",
"partial-gen-params-no-additional-lang/[lang]/[slug]/page.js",
"partial-gen-params-no-additional-lang/[lang]/[slug]/page_client-reference-manifest.js",
"partial-gen-params-no-additional-lang/en/RAND.html",
"partial-gen-params-no-additional-lang/en/RAND.rsc",
"partial-gen-params-no-additional-lang/en/first.html",
"partial-gen-params-no-additional-lang/en/first.rsc",
"partial-gen-params-no-additional-lang/en/second.html",
"partial-gen-params-no-additional-lang/en/second.rsc",
"partial-gen-params-no-additional-lang/fr/RAND.html",
"partial-gen-params-no-additional-lang/fr/RAND.rsc",
"partial-gen-params-no-additional-lang/fr/first.html",
"partial-gen-params-no-additional-lang/fr/first.rsc",
"partial-gen-params-no-additional-lang/fr/second.html",
"partial-gen-params-no-additional-lang/fr/second.rsc",
"partial-gen-params-no-additional-slug/[lang]/[slug]/page.js",
"partial-gen-params-no-additional-slug/[lang]/[slug]/page_client-reference-manifest.js",
"partial-gen-params-no-additional-slug/en/RAND.html",
"partial-gen-params-no-additional-slug/en/RAND.rsc",
"partial-gen-params-no-additional-slug/en/first.html",
"partial-gen-params-no-additional-slug/en/first.rsc",
"partial-gen-params-no-additional-slug/en/second.html",
"partial-gen-params-no-additional-slug/en/second.rsc",
"partial-gen-params-no-additional-slug/fr/RAND.html",
"partial-gen-params-no-additional-slug/fr/RAND.rsc",
"partial-gen-params-no-additional-slug/fr/first.html",
"partial-gen-params-no-additional-slug/fr/first.rsc",
"partial-gen-params-no-additional-slug/fr/second.html",
"partial-gen-params-no-additional-slug/fr/second.rsc",
"partial-gen-params/[lang]/[slug]/page.js",
"partial-gen-params/[lang]/[slug]/page_client-reference-manifest.js",
"react-fetch-deduping-edge/page.js",
"react-fetch-deduping-edge/page_client-reference-manifest.js",
"react-fetch-deduping-node/page.js",
"react-fetch-deduping-node/page_client-reference-manifest.js",
"response-url/page.js",
"response-url/page_client-reference-manifest.js",
"route-handler-edge/revalidate-360/route.js",
"route-handler/post/route.js",
"route-handler/revalidate-360-isr/route.js",
"route-handler/revalidate-360/route.js",
"route-handler/static-cookies/route.js",
"ssg-draft-mode.html",
"ssg-draft-mode.rsc",
"ssg-draft-mode/[[...route]]/page.js",
"ssg-draft-mode/[[...route]]/page_client-reference-manifest.js",
"ssg-draft-mode/test-2.html",
"ssg-draft-mode/test-2.rsc",
"ssg-draft-mode/test.html",
"ssg-draft-mode/test.rsc",
"ssr-auto/cache-no-store/page.js",
"ssr-auto/cache-no-store/page_client-reference-manifest.js",
"ssr-auto/fetch-revalidate-zero/page.js",
"ssr-auto/fetch-revalidate-zero/page_client-reference-manifest.js",
"ssr-forced/page.js",
"ssr-forced/page_client-reference-manifest.js",
"stale-cache-serving-edge/app-page/page.js",
"stale-cache-serving-edge/app-page/page_client-reference-manifest.js",
"stale-cache-serving-edge/route-handler/route.js",
"stale-cache-serving/app-page/page.js",
"stale-cache-serving/app-page/page_client-reference-manifest.js",
"stale-cache-serving/route-handler/route.js",
"static-to-dynamic-error-forced/[id]/page.js",
"static-to-dynamic-error-forced/[id]/page_client-reference-manifest.js",
"static-to-dynamic-error/[id]/page.js",
"static-to-dynamic-error/[id]/page_client-reference-manifest.js",
"variable-config-revalidate/revalidate-3.html",
"variable-config-revalidate/revalidate-3.rsc",
"variable-config-revalidate/revalidate-3/page.js",
"variable-config-revalidate/revalidate-3/page_client-reference-manifest.js",
"variable-revalidate-edge/body/page.js",
"variable-revalidate-edge/body/page_client-reference-manifest.js",
"variable-revalidate-edge/encoding/page.js",
"variable-revalidate-edge/encoding/page_client-reference-manifest.js",
"variable-revalidate-edge/no-store/page.js",
"variable-revalidate-edge/no-store/page_client-reference-manifest.js",
"variable-revalidate-edge/post-method-request/page.js",
"variable-revalidate-edge/post-method-request/page_client-reference-manifest.js",
"variable-revalidate-edge/post-method/page.js",
"variable-revalidate-edge/post-method/page_client-reference-manifest.js",
"variable-revalidate-edge/revalidate-3/page.js",
"variable-revalidate-edge/revalidate-3/page_client-reference-manifest.js",
"variable-revalidate/authorization.html",
"variable-revalidate/authorization.rsc",
"variable-revalidate/authorization/page.js",
"variable-revalidate/authorization/page_client-reference-manifest.js",
"variable-revalidate/cookie.html",
"variable-revalidate/cookie.rsc",
"variable-revalidate/cookie/page.js",
"variable-revalidate/cookie/page_client-reference-manifest.js",
"variable-revalidate/encoding.html",
"variable-revalidate/encoding.rsc",
"variable-revalidate/encoding/page.js",
"variable-revalidate/encoding/page_client-reference-manifest.js",
"variable-revalidate/headers-instance.html",
"variable-revalidate/headers-instance.rsc",
"variable-revalidate/headers-instance/page.js",
"variable-revalidate/headers-instance/page_client-reference-manifest.js",
"variable-revalidate/no-store/page.js",
"variable-revalidate/no-store/page_client-reference-manifest.js",
"variable-revalidate/post-method-request/page.js",
"variable-revalidate/post-method-request/page_client-reference-manifest.js",
"variable-revalidate/post-method.html",
"variable-revalidate/post-method.rsc",
"variable-revalidate/post-method/page.js",
"variable-revalidate/post-method/page_client-reference-manifest.js",
"variable-revalidate/revalidate-3.html",
"variable-revalidate/revalidate-3.rsc",
"variable-revalidate/revalidate-3/page.js",
"variable-revalidate/revalidate-3/page_client-reference-manifest.js",
"variable-revalidate/revalidate-360-isr.html",
"variable-revalidate/revalidate-360-isr.rsc",
"variable-revalidate/revalidate-360-isr/page.js",
"variable-revalidate/revalidate-360-isr/page_client-reference-manifest.js",
"variable-revalidate/revalidate-360/page.js",
"variable-revalidate/revalidate-360/page_client-reference-manifest.js",
"variable-revalidate/status-code/page.js",
"variable-revalidate/status-code/page_client-reference-manifest.js",
]
`)
})
it('should have correct prerender-manifest entries', async () => {
@ -1033,22 +1029,6 @@ createNextDescribe(
"initialRevalidateSeconds": false,
"srcRoute": "/hooks/use-search-params/force-static",
},
"/hooks/use-search-params/static-bailout": {
"dataRoute": "/hooks/use-search-params/static-bailout.rsc",
"experimentalBypassFor": [
{
"key": "Next-Action",
"type": "header",
},
{
"key": "content-type",
"type": "header",
"value": "multipart/form-data",
},
],
"initialRevalidateSeconds": false,
"srcRoute": "/hooks/use-search-params/static-bailout",
},
"/hooks/use-search-params/with-suspense": {
"dataRoute": "/hooks/use-search-params/with-suspense.rsc",
"experimentalBypassFor": [
@ -2888,26 +2868,6 @@ createNextDescribe(
describe('useSearchParams', () => {
describe('client', () => {
it('should bailout to client rendering - without suspense boundary', async () => {
const url =
'/hooks/use-search-params/static-bailout?first=value&second=other&third'
const browser = await next.browser(url)
expect(await browser.elementByCss('#params-first').text()).toBe(
'value'
)
expect(await browser.elementByCss('#params-second').text()).toBe(
'other'
)
expect(await browser.elementByCss('#params-third').text()).toBe('')
expect(await browser.elementByCss('#params-not-real').text()).toBe(
'N/A'
)
const $ = await next.render$(url)
expect($('meta[content=noindex]').length).toBe(0)
})
it('should bailout to client rendering - with suspense boundary', async () => {
const url =
'/hooks/use-search-params/with-suspense?first=value&second=other&third'
@ -2977,14 +2937,6 @@ createNextDescribe(
// Don't run these tests in dev mode since they won't be statically generated
if (!isDev) {
describe('server response', () => {
it('should bailout to client rendering - without suspense boundary', async () => {
const res = await next.fetch(
'/hooks/use-search-params/static-bailout'
)
const html = await res.text()
expect(html).toInclude('<html id="__next_error__">')
})
it('should bailout to client rendering - with suspense boundary', async () => {
const res = await next.fetch(
'/hooks/use-search-params/with-suspense'

View file

@ -1,10 +0,0 @@
import UseSearchParams from '../search-params'
export default function Page() {
return (
<>
<p id="hooks-use-search-params" />
<UseSearchParams />
</>
)
}

View file

@ -13,7 +13,7 @@ module.exports = {
afterFiles: [
{
source: '/rewritten-use-search-params',
destination: '/hooks/use-search-params/static-bailout',
destination: '/hooks/use-search-params/with-suspense',
},
{
source: '/rewritten-use-pathname',

View file

@ -1,8 +1,17 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Component />
</Suspense>
)
}
function Component() {
const params = useSearchParams()
return (

View file

@ -0,0 +1,3 @@
export default () => {
return <div id="dynamic">Hello, world!</div>
}

View file

@ -0,0 +1,9 @@
import dynamic from 'next/dynamic'
const Dynamic = dynamic(() => import('./dynamic'), {
ssr: false,
})
export default () => {
return <Dynamic />
}

View file

@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,12 @@
import { Suspense } from 'react'
export default function Layout({ children }) {
return (
<html>
<head />
<body>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</body>
</html>
)
}

View file

@ -0,0 +1,8 @@
'use client'
import { useSearchParams } from 'next/navigation'
export default function Page() {
useSearchParams()
return <div>Page</div>
}

View file

@ -0,0 +1,69 @@
import { createNextDescribe } from 'e2e-utils'
createNextDescribe(
'missing-suspense-with-csr-bailout',
{
files: __dirname,
skipStart: true,
},
({ next, isNextDev }) => {
if (isNextDev) {
it.skip('skip test for dev mode', () => {})
return
}
beforeEach(async () => {
await next.clean()
})
describe('useSearchParams', () => {
const message = `useSearchParams() should be wrapped in a suspense boundary at page "/".`
it('should pass build if useSearchParams is wrapped in a suspense boundary', async () => {
await expect(next.build()).resolves.toEqual({
exitCode: 0,
cliOutput: expect.not.stringContaining(message),
})
})
it('should fail build if useSearchParams is not wrapped in a suspense boundary', async () => {
await next.renameFile('app/layout.js', 'app/layout-suspense.js')
await next.renameFile('app/layout-no-suspense.js', 'app/layout.js')
await expect(next.build()).resolves.toEqual({
exitCode: 1,
cliOutput: expect.stringContaining(message),
})
await next.renameFile('app/layout.js', 'app/layout-no-suspense.js')
await next.renameFile('app/layout-suspense.js', 'app/layout.js')
})
})
describe('next/dynamic', () => {
beforeEach(async () => {
await next.start()
})
it('does not emit errors related to bailing out of client side rendering', async () => {
const browser = await next.browser('/dynamic', {
pushErrorAsConsoleLog: true,
})
try {
await browser.waitForElementByCss('#dynamic')
// await new Promise((resolve) => setTimeout(resolve, 1000))
expect(await browser.log()).not.toContainEqual(
expect.objectContaining({
source: 'error',
})
)
} finally {
await browser.close()
}
})
})
}
)

View file

@ -0,0 +1,8 @@
/** @type {import("next").NextConfig} */
const config = {
experimental: {
missingSuspenseWithCSRBailout: true,
},
}
module.exports = config

View file

@ -13,7 +13,9 @@ export default function Root({ children }) {
return (
<html className="root-layout-html">
<body>
<NotFoundTrigger />
<React.Suspense fallback={<div>Loading...</div>}>
<NotFoundTrigger />
</React.Suspense>
<button id="trigger-not-found" onClick={() => setClicked(true)}>
Click to not found
</button>

View file

@ -129,10 +129,12 @@ export abstract class BrowserInterface implements PromiseLike<any> {
disableCache,
cpuThrottleRate,
beforePageLoad,
pushErrorAsConsoleLog,
}: {
disableCache?: boolean
cpuThrottleRate?: number
beforePageLoad?: Function
pushErrorAsConsoleLog?: boolean
}
): Promise<void> {}
async get(url: string): Promise<void> {}

View file

@ -197,6 +197,7 @@ export class Playwright extends BrowserInterface {
opts?: {
disableCache: boolean
cpuThrottleRate: number
pushErrorAsConsoleLog?: boolean
beforePageLoad?: (...args: any[]) => void
}
) {
@ -225,6 +226,10 @@ export class Playwright extends BrowserInterface {
})
page.on('pageerror', (error) => {
console.error('page error', error)
if (opts?.pushErrorAsConsoleLog) {
pageLogs.push({ source: 'error', message: error.message })
}
})
page.on('request', (req) => {
this.eventCallbacks.request.forEach((cb) => cb(req))

View file

@ -1,6 +1,6 @@
import os from 'os'
import path from 'path'
import fs from 'fs-extra'
import { existsSync, promises as fs } from 'fs'
import treeKill from 'tree-kill'
import type { NextConfig } from 'next'
import { FileRef } from '../e2e-utils'
@ -101,17 +101,18 @@ export class NextInstance {
`FileRef passed to "files" in "createNext" is not a directory ${files.fsPath}`
)
}
await fs.copy(files.fsPath, this.testDir)
await fs.cp(files.fsPath, this.testDir, { recursive: true })
} else {
for (const filename of Object.keys(files)) {
const item = files[filename]
const outputFilename = path.join(this.testDir, filename)
if (typeof item === 'string') {
await fs.ensureDir(path.dirname(outputFilename))
await fs.mkdir(path.dirname(outputFilename), { recursive: true })
await fs.writeFile(outputFilename, item)
} else {
await fs.copy(item.fsPath, outputFilename)
await fs.cp(item.fsPath, outputFilename, { recursive: true })
}
}
}
@ -159,7 +160,7 @@ export class NextInstance {
if (skipInstall || skipIsolatedNext) {
const pkgScripts = (this.packageJson['scripts'] as {}) || {}
await fs.ensureDir(this.testDir)
await fs.mkdir(this.testDir, { recursive: true })
await fs.writeFile(
path.join(this.testDir, 'package.json'),
JSON.stringify(
@ -194,7 +195,9 @@ export class NextInstance {
!this.packageJson &&
!(global as any).isNextDeploy
) {
await fs.copy(process.env.NEXT_TEST_STARTER, this.testDir)
await fs.cp(process.env.NEXT_TEST_STARTER, this.testDir, {
recursive: true,
})
} else {
const { installDir } = await createNextInstall({
parentSpan: rootSpan,
@ -219,7 +222,7 @@ export class NextInstance {
file.startsWith('next.config.')
)
if (await fs.pathExists(path.join(this.testDir, 'next.config.js'))) {
if (existsSync(path.join(this.testDir, 'next.config.js'))) {
nextConfigFile = 'next.config.js'
}
@ -317,7 +320,10 @@ export class NextInstance {
]
for (const file of await fs.readdir(this.testDir)) {
if (!keptFiles.includes(file)) {
await fs.remove(path.join(this.testDir, file))
await fs.rm(path.join(this.testDir, file), {
recursive: true,
force: true,
})
}
}
await this.writeInitialFiles()
@ -359,7 +365,7 @@ export class NextInstance {
if (process.env.TRACE_PLAYWRIGHT) {
await fs
.copy(
.cp(
path.join(this.testDir, '.next/trace'),
path.join(
__dirname,
@ -371,7 +377,8 @@ export class NextInstance {
)
.replace(/\//g, '-')}`,
`next-trace`
)
),
{ recursive: true }
)
.catch((e) => {
require('console').error(e)
@ -379,7 +386,7 @@ export class NextInstance {
}
if (!process.env.NEXT_TEST_SKIP_CLEANUP) {
await fs.remove(this.testDir)
await fs.rm(this.testDir, { recursive: true, force: true })
}
require('console').log(`destroyed next instance`)
} catch (err) {
@ -405,13 +412,15 @@ export class NextInstance {
// TODO: block these in deploy mode
public async hasFile(filename: string) {
return fs.pathExists(path.join(this.testDir, filename))
return existsSync(path.join(this.testDir, filename))
}
public async readFile(filename: string) {
return fs.readFile(path.join(this.testDir, filename), 'utf8')
}
public async readJSON(filename: string) {
return fs.readJSON(path.join(this.testDir, filename))
return JSON.parse(
await fs.readFile(path.join(this.testDir, filename), 'utf-8')
)
}
private async handleDevWatchDelayBeforeChange(filename: string) {
// This is a temporary workaround for turbopack starting watching too late.
@ -443,8 +452,8 @@ export class NextInstance {
await this.handleDevWatchDelayBeforeChange(filename)
const outputPath = path.join(this.testDir, filename)
const newFile = !(await fs.pathExists(outputPath))
await fs.ensureDir(path.dirname(outputPath))
const newFile = !existsSync(outputPath)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(
outputPath,
typeof content === 'function'
@ -467,12 +476,13 @@ export class NextInstance {
path.join(this.testDir, filename),
path.join(this.testDir, newFilename)
)
await this.handleDevWatchDelayAfterChange(filename)
}
public async renameFolder(foldername: string, newFoldername: string) {
await this.handleDevWatchDelayBeforeChange(foldername)
await fs.move(
await fs.rename(
path.join(this.testDir, foldername),
path.join(this.testDir, newFoldername)
)
@ -481,7 +491,11 @@ export class NextInstance {
public async deleteFile(filename: string) {
await this.handleDevWatchDelayBeforeChange(filename)
await fs.remove(path.join(this.testDir, filename))
await fs.rm(path.join(this.testDir, filename), {
recursive: true,
force: true,
})
await this.handleDevWatchDelayAfterChange(filename)
}

View file

@ -69,6 +69,7 @@ export default async function webdriver(
headless?: boolean
ignoreHTTPSErrors?: boolean
cpuThrottleRate?: number
pushErrorAsConsoleLog?: boolean
}
): Promise<BrowserInterface> {
let CurrentInterface: new () => BrowserInterface
@ -89,6 +90,7 @@ export default async function webdriver(
ignoreHTTPSErrors,
headless,
cpuThrottleRate,
pushErrorAsConsoleLog,
} = options
// we import only the needed interface
@ -133,6 +135,7 @@ export default async function webdriver(
disableCache,
cpuThrottleRate,
beforePageLoad,
pushErrorAsConsoleLog,
})
console.log(`\n> Loaded browser with ${fullUrl}\n`)