Skip to content

Commit

Permalink
Add stack trace to client rendering bailout error (#61200)
Browse files Browse the repository at this point in the history
When there's `useSearchParams` hook triggers the bailout to client side
rendering, users might hard to find where it's from since it could
either from users code base or third party libraries. Adding the stack
trace for it so they could at least investigate which line is throwing
from the server bundle. Will improve it in the later future when we can
give more insights.

#### After
```
 ⨯ useSearchParams() should be wrapped in a suspense boundary at page "/". Read more: https://
nextjs.org/docs/messages/missing-suspense-with-csr-bailout
    at a (/private/var/folders/gy/kq4zjn8s0ljf9sfjyyh_nj640000gn/T/next-install-aa5f331b7f6af2
82fd9bab0f69685454d1f50dc8f3c775da23d4e5e807a970cb/.next/server/chunks/846.js:1:9912)
    at h (/private/var/folders/gy/kq4zjn8s0ljf9sfjyyh_nj640000gn/T/next-install-aa5f331b7f6af2
82fd9bab0f69685454d1f50dc8f3c775da23d4e5e807a970cb/.next/server/chunks/846.js:1:22018)
    at a (/private/var/folders/gy/kq4zjn8s0ljf9sfjyyh_nj640000gn/T/next-install-aa5f331b7f6af2
82fd9bab0f69685454d1f50dc8f3c775da23d4e5e807a970cb/.next/server/app/page.js:1:2518)
```

#### Before
```
 ⨯ useSearchParams() should be wrapped in a suspense boundary at page "/". Read more: https://
nextjs.org/docs/messages/missing-suspense-with-csr-bailout
```

Closes NEXT-2239
  • Loading branch information
huozhi committed Jan 26, 2024
1 parent d4b520a commit c6a061a
Show file tree
Hide file tree
Showing 8 changed files with 59 additions and 30 deletions.
16 changes: 16 additions & 0 deletions packages/next/src/lib/format-server-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ function setMessage(error: Error, message: string): void {
}
}

/**
* Input:
* Error: Something went wrong
at funcName (/path/to/file.js:10:5)
at anotherFunc (/path/to/file.js:15:10)
* Output:
at funcName (/path/to/file.js:10:5)
at anotherFunc (/path/to/file.js:15:10)
*/
export function getStackWithoutErrorMessage(error: Error): string {
const stack = error.stack
if (!stack) return ''
return stack.replace(/^[^\n]*\n/, '')
}

export function formatServerError(error: Error): void {
if (typeof error?.message !== 'string') return

Expand Down
4 changes: 3 additions & 1 deletion packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { isDynamicServerError } from '../../client/components/hooks-server-conte
import { useFlightResponse } from './use-flight-response'
import { isStaticGenBailoutError } from '../../client/components/static-generation-bailout'
import { isInterceptionRouteAppPath } from '../future/helpers/interception-routes'
import { getStackWithoutErrorMessage } from '../../lib/format-server-error'

export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
Expand Down Expand Up @@ -1014,8 +1015,9 @@ async function renderToHTMLOrFlightImpl(
console.log()

if (renderOpts.experimental.missingSuspenseWithCSRBailout) {
const stack = getStackWithoutErrorMessage(err)
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`
`${err.reason} should be wrapped in a suspense boundary at page "${pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout\n${stack}`
)

throw err
Expand Down
3 changes: 1 addition & 2 deletions packages/next/src/server/app-render/create-error-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ export function createErrorHandler({
const { logAppDirError } =
require('../dev/log-app-dir-error') as typeof import('../dev/log-app-dir-error')
logAppDirError(err)
}
if (process.env.NODE_ENV === 'production') {
} else {
console.error(err)
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { Suspense } from 'react'

export default function Layout({ children }) {
return (
<html>
<head />
<body>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</body>
<body>{children}</body>
</html>
)
}
13 changes: 11 additions & 2 deletions test/e2e/app-dir/missing-suspense-with-csr-bailout/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import { useSearchParams } from 'next/navigation'

export default function Page() {
function SearchParams() {
useSearchParams()
return <div>Page</div>
return null
}

export default function Page() {
return (
<>
<SearchParams />
<div>Page</div>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,36 @@ createNextDescribe(
describe('useSearchParams', () => {
const message = `useSearchParams() should be wrapped in a suspense boundary at page "/".`

it('should fail build if useSearchParams is not wrapped in a suspense boundary', async () => {
const { exitCode } = await next.build()
expect(exitCode).toBe(1)
expect(next.cliOutput).toContain(message)
// Can show the trace where the searchParams hook is used
expect(next.cliOutput).toMatch(/at.*server[\\/]app[\\/]page.js/)
})

it('should pass build if useSearchParams is wrapped in a suspense boundary', async () => {
await next.renameFile('app/layout.js', 'app/layout-no-suspense.js')
await next.renameFile('app/layout-suspense.js', 'app/layout.js')

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.renameFile('app/page.js', 'app/_page.js')
await next.start()
})
afterEach(async () => {
await next.renameFile('app/_page.js', 'app/page.js')
})

it('does not emit errors related to bailing out of client side rendering', async () => {
const browser = await next.browser('/dynamic', {
Expand All @@ -53,8 +58,6 @@ createNextDescribe(
try {
await browser.waitForElementByCss('#dynamic')

// await new Promise((resolve) => setTimeout(resolve, 1000))

expect(await browser.log()).not.toContainEqual(
expect.objectContaining({
source: 'error',
Expand Down

0 comments on commit c6a061a

Please sign in to comment.