Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add docs page for uncaught DynamicServerErrors #53402

Merged
merged 13 commits into from
Aug 8, 2023
63 changes: 63 additions & 0 deletions errors/dynamic-server-error.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: DynamicServerError - Dynamic Server Usage
---

#### Why This Message Occurred

You attempted to use a Next.js function that depends on Async Context (such as `headers` or `cookies` from `next/headers`) but it was not bound to the same call stack as the function that ran it. Next.js will throw a `DynamicServerError` to know when to opt a page into dynamic rendering. However, when it's uncaught, that means that the async context was lost.
ztanner marked this conversation as resolved.
Show resolved Hide resolved

## What is Async Context?

[Async Context](https://github.com/tc39/proposal-async-context) is a way to pass data within the same call stack, even through asynchronous operations. This is very useful in Next.js, where functions like cookies or headers might be called from anywhere within a React component tree or other functions during React rendering. However, this context can be lost if certain operations like setTimeout or unhandled Promise rejections are used.
ztanner marked this conversation as resolved.
Show resolved Hide resolved

## Scenarios that can cause this to happen

- The function was called inside of a `setTimeout` or `setInterval`, causing the value to be read outside of the call stack that the context was bound to
ztanner marked this conversation as resolved.
Show resolved Hide resolved
ztanner marked this conversation as resolved.
Show resolved Hide resolved
leerob marked this conversation as resolved.
Show resolved Hide resolved
- The function was called after an async operation, but the promise wasn't awaited. This can cause the function to be called after the async operation has completed, resulting in a new execution context and loss of the original async context.

### Example of Incorrect Usage

```jsx filename="app/page.js"
import { cookies } from 'next/headers'

async function getCookieData() {
return new Promise((resolve) =>
setTimeout(() => {
// cookies will be called outside of the async context, causing a build-time error
resolve(cookies().getAll())
}, 1000)
)
}

export default async function Page() {
const cookieData = await getCookieData()
return <div>Hello World</div>
}
```

## Possible Ways to Fix It

**Manage Execution Contexts Correctly:** JavaScript operations like `setTimeout`, `setInterval`, event handlers, and Promises create new execution contexts. You need to maintain the async context when using these operations. Some strategies include:

- Invoke the function that depends on the async context outside of the function that creates a new execution context.
- Ensure that you await Promises that invoke a function that depends on async context, otherwise the function may be called after the async operation has completed.

### Example of Correct Usage

```jsx filename="app/page.js"
import { cookies } from 'next/headers'

async function getCookieData() {
const cookieData = cookies().getAll()
return new Promise((resolve) =>
setTimeout(() => {
resolve(cookieData)
}, 1000)
)
}

export default async function Page() {
const cookieData = await getCookieData()
return <div>Hello World</div>
}
```
18 changes: 13 additions & 5 deletions packages/next/src/client/components/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,37 @@ import { staticGenerationBailout } from './static-generation-bailout'
import { DraftMode } from './draft-mode'

export function headers() {
if (staticGenerationBailout('headers')) {
if (
staticGenerationBailout('headers', {
link: 'https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering',
})
) {
return HeadersAdapter.seal(new Headers({}))
}

const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: Method expects to have requestAsyncStorage, none available`
`Invariant: headers() expects to have requestAsyncStorage, none available.`
)
}

return requestStore.headers
}

export function cookies() {
if (staticGenerationBailout('cookies')) {
if (
staticGenerationBailout('cookies', {
link: 'https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering',
})
) {
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
}

const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: Method expects to have requestAsyncStorage, none available`
`Invariant: cookies() expects to have requestAsyncStorage, none available.`
)
}

Expand All @@ -53,7 +61,7 @@ export function draftMode() {
const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: Method expects to have requestAsyncStorage, none available`
`Invariant: draftMode() expects to have requestAsyncStorage, none available.`
)
}
return new DraftMode(requestStore.draftMode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@ class StaticGenBailoutError extends Error {
code = 'NEXT_STATIC_GEN_BAILOUT'
}

type BailoutOpts = { dynamic?: string; link?: string }

export type StaticGenerationBailout = (
reason: string,
opts?: { dynamic?: string; link?: string }
opts?: BailoutOpts
) => boolean | never

function formatErrorMessage(reason: string, opts?: BailoutOpts) {
const { dynamic, link } = opts || {}
const suffix = link ? ` See more info here: ${link}` : ''
return `Page${
dynamic ? ` with \`dynamic = "${dynamic}"\`` : ''
} couldn't be rendered statically because it used \`${reason}\`.${suffix}`
}

export const staticGenerationBailout: StaticGenerationBailout = (
reason,
opts
Expand All @@ -21,10 +31,8 @@ export const staticGenerationBailout: StaticGenerationBailout = (
}

if (staticGenerationStore?.dynamicShouldError) {
const { dynamic = 'error', link } = opts || {}
const suffix = link ? ` See more info here: ${link}` : ''
throw new StaticGenBailoutError(
`Page with \`dynamic = "${dynamic}"\` couldn't be rendered statically because it used \`${reason}\`.${suffix}`
formatErrorMessage(reason, { ...opts, dynamic: opts?.dynamic ?? 'error' })
)
}

Expand All @@ -33,8 +41,14 @@ export const staticGenerationBailout: StaticGenerationBailout = (
}

if (staticGenerationStore?.isStaticGeneration) {
const err = new DynamicServerError(reason)

const err = new DynamicServerError(
formatErrorMessage(reason, {
...opts,
// this error should be caught by Next to bail out of static generation
// in case it's uncaught, this link provides some additional context as to why
link: 'https://nextjs.org/docs/messages/dynamic-server-error',
})
)
staticGenerationStore.dynamicUsageDescription = reason
staticGenerationStore.dynamicUsageStack = err.stack

Expand Down
4 changes: 2 additions & 2 deletions test/development/acceptance-app/dynamic-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe('dynamic = "error" in devmode', () => {

await session.hasRedbox(true)
console.log(await session.getRedboxDescription())
expect(await session.getRedboxDescription()).toBe(
`Error: Page with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`.`
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
`"Error: Page with \`dynamic = \\"error\\"\` couldn't be rendered statically because it used \`cookies\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering"`
)

await cleanup()
Expand Down
7 changes: 7 additions & 0 deletions test/e2e/app-dir/headers-static-bailout/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { nanoid } from 'nanoid'
import { headers } from 'next/headers'

export default function Page() {
headers()
return (
<>
<h1>Dynamic Page</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { nanoid } from 'nanoid'

export default function Page() {
return (
<>
<h1>Static Page</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createNextDescribe } from '../../../lib/e2e-utils'
import { outdent } from 'outdent'

createNextDescribe(
'headers-static-bailout',
{
files: __dirname,
dependencies: {
nanoid: '4.0.1',
},
},
({ next, isNextStart }) => {
if (!isNextStart) {
it('should skip', () => {})
return
}

it('should bailout when using an import from next/headers', async () => {
const url = '/page-with-headers'
const $ = await next.render$(url)
expect($('h1').text()).toBe('Dynamic Page')

// Check if the page is not statically generated.
const id = $('#nanoid').text()
const $2 = await next.render$(url)
const id2 = $2('#nanoid').text()
expect(id).not.toBe(id2)
})

it('should not bailout when not using headers', async () => {
const url = '/page-without-headers'

const $ = await next.render$(url)
expect($('h1').text()).toBe('Static Page')

// Check if the page is not statically generated.
const id = $('#nanoid').text()
const $2 = await next.render$(url)
const id2 = $2('#nanoid').text()
expect(id).toBe(id2)
})

it('it provides a helpful link in case static generation bailout is uncaught', async () => {
await next.stop()
await next.patchFile(
'app/server-components-page/page.tsx',
outdent`
import { cookies } from 'next/headers'

async function foo() {
return new Promise((resolve) =>
// break out of the expected async context, causing an uncaught build-time error
setTimeout(() => {
resolve(cookies().getAll())
}, 1000)
)
}

export default async function Page() {
await foo()
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
expect(cliOutput).toContain(
'https://nextjs.org/docs/messages/dynamic-server-error'
)
})
}
)
6 changes: 6 additions & 0 deletions test/e2e/app-dir/headers-static-bailout/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig