From fef6f82abafdad6c32a61ebbf4bff77f634bfc2d Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Tue, 8 Aug 2023 03:49:53 -0700 Subject: [PATCH] Add docs page for uncaught DynamicServerErrors (#53402) When using imports from `next/headers` in a layout or page, `StaticGenerationBailout` will throw an error to indicate Next.js should fallback to dynamic rendering. However, when async context is lost, this error is uncaught and leads to a confusing error message at build time. This attempts to improve DX surrounding this error by linking out to a page that explains when it might happen. I've also tweaked `StaticGenerationBailout` to always throw a fully descriptive reason as opposed to just `DynamicServerError: Dynamic server usage: cookies` Closes NEXT-1181 Fixes #49373 --------- Co-authored-by: Lee Robinson Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- errors/dynamic-server-error.mdx | 65 +++++++++++++++++ .../next/src/client/components/headers.ts | 18 +++-- .../components/static-generation-bailout.ts | 26 +++++-- .../acceptance-app/dynamic-error.test.ts | 4 +- .../headers-static-bailout/app/layout.tsx | 7 ++ .../app/page-with-headers/page.tsx | 12 ++++ .../app/page-without-headers/page.tsx | 10 +++ .../headers-static-bailout.test.ts | 71 +++++++++++++++++++ .../headers-static-bailout/next.config.js | 6 ++ 9 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 errors/dynamic-server-error.mdx create mode 100644 test/e2e/app-dir/headers-static-bailout/app/layout.tsx create mode 100644 test/e2e/app-dir/headers-static-bailout/app/page-with-headers/page.tsx create mode 100644 test/e2e/app-dir/headers-static-bailout/app/page-without-headers/page.tsx create mode 100644 test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts create mode 100644 test/e2e/app-dir/headers-static-bailout/next.config.js diff --git a/errors/dynamic-server-error.mdx b/errors/dynamic-server-error.mdx new file mode 100644 index 0000000000000..398ffc03fc75b --- /dev/null +++ b/errors/dynamic-server-error.mdx @@ -0,0 +1,65 @@ +--- +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 (e.g., calling `cookies()` inside of a `setTimeout` or `setInterval`). + +While generating static pages, Next.js will throw a `DynamicServerError` if it detects usage of a dynamic function, and catch it to automatically opt the page into dynamic rendering. However, when it's uncaught, it will result in this build-time error. + +## 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. + +## 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. +- 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
Hello World
+} +``` + +## 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
Hello World
+} +``` diff --git a/packages/next/src/client/components/headers.ts b/packages/next/src/client/components/headers.ts index 2b239c9b8763f..d090264391e7c 100644 --- a/packages/next/src/client/components/headers.ts +++ b/packages/next/src/client/components/headers.ts @@ -10,14 +10,18 @@ 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.` ) } @@ -25,14 +29,18 @@ export function 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.` ) } @@ -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) diff --git a/packages/next/src/client/components/static-generation-bailout.ts b/packages/next/src/client/components/static-generation-bailout.ts index 8f6b63f7c2295..c5072218f035c 100644 --- a/packages/next/src/client/components/static-generation-bailout.ts +++ b/packages/next/src/client/components/static-generation-bailout.ts @@ -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 @@ -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' }) ) } @@ -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 diff --git a/test/development/acceptance-app/dynamic-error.test.ts b/test/development/acceptance-app/dynamic-error.test.ts index 4cf03fceeab62..a5cc5ec9daba3 100644 --- a/test/development/acceptance-app/dynamic-error.test.ts +++ b/test/development/acceptance-app/dynamic-error.test.ts @@ -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() diff --git a/test/e2e/app-dir/headers-static-bailout/app/layout.tsx b/test/e2e/app-dir/headers-static-bailout/app/layout.tsx new file mode 100644 index 0000000000000..e7077399c03ce --- /dev/null +++ b/test/e2e/app-dir/headers-static-bailout/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/headers-static-bailout/app/page-with-headers/page.tsx b/test/e2e/app-dir/headers-static-bailout/app/page-with-headers/page.tsx new file mode 100644 index 0000000000000..00c49b0df56de --- /dev/null +++ b/test/e2e/app-dir/headers-static-bailout/app/page-with-headers/page.tsx @@ -0,0 +1,12 @@ +import { nanoid } from 'nanoid' +import { headers } from 'next/headers' + +export default function Page() { + headers() + return ( + <> +

Dynamic Page

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/headers-static-bailout/app/page-without-headers/page.tsx b/test/e2e/app-dir/headers-static-bailout/app/page-without-headers/page.tsx new file mode 100644 index 0000000000000..3592369eb06a6 --- /dev/null +++ b/test/e2e/app-dir/headers-static-bailout/app/page-without-headers/page.tsx @@ -0,0 +1,10 @@ +import { nanoid } from 'nanoid' + +export default function Page() { + return ( + <> +

Static Page

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts b/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts new file mode 100644 index 0000000000000..1375daf577383 --- /dev/null +++ b/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts @@ -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
Hello World
+ } + ` + ) + const { cliOutput } = await next.build() + expect(cliOutput).toContain( + 'https://nextjs.org/docs/messages/dynamic-server-error' + ) + }) + } +) diff --git a/test/e2e/app-dir/headers-static-bailout/next.config.js b/test/e2e/app-dir/headers-static-bailout/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/headers-static-bailout/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig