Fix: Allow dynamic rendering when root layout is wrapped in Suspense (#82378)

The prelude is empty when the entire app is suspended during prerender.
Typically this indicates a lack of suspense boundary in the app, which
creates no static shell, thus throwing error. However, if the root
layout itself is dynamic but wrapped under a suspense, we need special
logic in this PR to detect that.
This commit is contained in:
Jude Gao
2025-08-12 00:18:40 +00:00
committed by GitHub
parent 4dc34109a4
commit cc5e9e6700
14 changed files with 180 additions and 56 deletions
@@ -44,6 +44,7 @@ import { isRedirectError, RedirectType } from './redirect-error'
import { pingVisibleLinks } from './links'
import RootErrorBoundary from './errors/root-error-boundary'
import DefaultGlobalError from './builtin/global-error'
import { RootLayoutBoundary } from '../../lib/framework/boundary-components'
const globalMutable: {
pendingMpaPath?: string
@@ -482,7 +483,10 @@ function Router({
let content = (
<RedirectBoundary>
{head}
{cache.rsc}
{/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout.
When users wrap their layout in <Suspense>, this creates the component stack pattern
"Suspense -> RootLayoutBoundary" which dynamic-rendering.ts uses to allow dynamic rendering. */}
<RootLayoutBoundary>{cache.rsc}</RootLayoutBoundary>
<AppRouterAnnouncer tree={tree} />
</RedirectBoundary>
)
@@ -1,32 +1,29 @@
'use client'
import type { ReactNode } from 'react'
import {
METADATA_BOUNDARY_NAME,
VIEWPORT_BOUNDARY_NAME,
OUTLET_BOUNDARY_NAME,
} from '../../../lib/metadata/metadata-constants'
ROOT_LAYOUT_BOUNDARY_NAME,
} from './boundary-constants'
// We use a namespace object to allow us to recover the name of the function
// at runtime even when production bundling/minification is used.
const NameSpace = {
[METADATA_BOUNDARY_NAME]: function ({
children,
}: {
children: React.ReactNode
}) {
[METADATA_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
return children
},
[VIEWPORT_BOUNDARY_NAME]: function ({
children,
}: {
children: React.ReactNode
}) {
[VIEWPORT_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
return children
},
[OUTLET_BOUNDARY_NAME]: function ({
[OUTLET_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
return children
},
[ROOT_LAYOUT_BOUNDARY_NAME]: function ({
children,
}: {
children: React.ReactNode
children: ReactNode
}) {
return children
},
@@ -46,3 +43,10 @@ export const OutletBoundary =
// We use slice(0) to trick the bundler into not inlining/minifying the function
// so it retains the name inferred from the namespace object
NameSpace[OUTLET_BOUNDARY_NAME.slice(0) as typeof OUTLET_BOUNDARY_NAME]
export const RootLayoutBoundary =
// We use slice(0) to trick the bundler into not inlining/minifying the function
// so it retains the name inferred from the namespace object
NameSpace[
ROOT_LAYOUT_BOUNDARY_NAME.slice(0) as typeof ROOT_LAYOUT_BOUNDARY_NAME
]
@@ -1,3 +1,4 @@
export const METADATA_BOUNDARY_NAME = '__next_metadata_boundary__'
export const VIEWPORT_BOUNDARY_NAME = '__next_viewport_boundary__'
export const OUTLET_BOUNDARY_NAME = '__next_outlet_boundary__'
export const ROOT_LAYOUT_BOUNDARY_NAME = '__next_root_layout_boundary__'
+1 -1
View File
@@ -37,7 +37,7 @@ import type { WorkStore } from '../../server/app-render/work-async-storage.exter
import {
METADATA_BOUNDARY_NAME,
VIEWPORT_BOUNDARY_NAME,
} from './metadata-constants'
} from '../framework/boundary-constants'
import { AsyncMetadataOutlet } from '../../client/components/metadata/async-metadata'
import { isPostpone } from '../../server/lib/router-utils/is-postpone'
import { createServerSearchParamsForMetadata } from '../../server/request/search-params'
@@ -20,7 +20,7 @@ import { StaticGenBailoutError } from '../../client/components/static-generation
import type { LoadingModuleData } from '../../shared/lib/app-router-context.shared-runtime'
import type { Params } from '../request/params'
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
import { OUTLET_BOUNDARY_NAME } from '../../lib/metadata/metadata-constants'
import { OUTLET_BOUNDARY_NAME } from '../../lib/framework/boundary-constants'
import type {
UseCacheLayoutComponentProps,
UseCachePageComponentProps,
@@ -40,7 +40,8 @@ import {
METADATA_BOUNDARY_NAME,
VIEWPORT_BOUNDARY_NAME,
OUTLET_BOUNDARY_NAME,
} from '../../lib/metadata/metadata-constants'
ROOT_LAYOUT_BOUNDARY_NAME,
} from '../../lib/framework/boundary-constants'
import { scheduleOnNextTick } from '../../lib/scheduler'
import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { InvariantError } from '../../shared/lib/invariant-error'
@@ -637,8 +638,27 @@ export function useDynamicRouteParams(expression: string) {
}
const hasSuspenseRegex = /\n\s+at Suspense \(<anonymous>\)/
const hasSuspenseAfterBodyOrHtmlRegex =
/\n\s+at (?:body|html) \(<anonymous>\)[\s\S]*?\n\s+at Suspense \(<anonymous>\)/
// Common implicit body tags that React will treat as body when placed directly in html
const bodyAndImplicitTags =
'body|div|main|section|article|aside|header|footer|nav|form|p|span|h1|h2|h3|h4|h5|h6'
// Detects when RootLayoutBoundary (our framework marker component) appears
// after Suspense in the component stack, indicating the root layout is wrapped
// within a Suspense boundary. Ensures no body/html/implicit-body components are in between.
//
// Example matches:
// at Suspense (<anonymous>)
// at __next_root_layout_boundary__ (<anonymous>)
//
// Or with other components in between (but not body/html/implicit-body):
// at Suspense (<anonymous>)
// at SomeComponent (<anonymous>)
// at __next_root_layout_boundary__ (<anonymous>)
const hasSuspenseBeforeRootLayoutWithoutBodyOrImplicitBodyRegex = new RegExp(
`\\n\\s+at Suspense \\(<anonymous>\\)(?:(?!\\n\\s+at (?:${bodyAndImplicitTags}) \\(<anonymous>\\))[\\s\\S])*?\\n\\s+at ${ROOT_LAYOUT_BOUNDARY_NAME} \\([^\\n]*\\)`
)
const hasMetadataRegex = new RegExp(
`\\n\\s+at ${METADATA_BOUNDARY_NAME}[\\n\\s]`
)
@@ -662,9 +682,14 @@ export function trackAllowedDynamicAccess(
} else if (hasViewportRegex.test(componentStack)) {
dynamicValidation.hasDynamicViewport = true
return
} else if (hasSuspenseAfterBodyOrHtmlRegex.test(componentStack)) {
// This prerender has a Suspense boundary above the body which
// effectively opts the page into allowing 100% dynamic rendering
} else if (
hasSuspenseBeforeRootLayoutWithoutBodyOrImplicitBodyRegex.test(
componentStack
)
) {
// For Suspense within body, the prelude wouldn't be empty so it wouldn't violate the empty static shells rule.
// But if you have Suspense above body, the prelude is empty but we allow that because having Suspense
// is an explicit signal from the user that they acknowledge the empty shell and want dynamic rendering.
dynamicValidation.hasAllowedDynamic = true
dynamicValidation.hasSuspenseAboveBody = true
return
@@ -36,7 +36,8 @@ export {
MetadataBoundary,
ViewportBoundary,
OutletBoundary,
} from '../../client/components/metadata/metadata-boundary'
RootLayoutBoundary,
} from '../../lib/framework/boundary-components'
export { preloadStyle, preloadFont, preconnect } from './rsc/preloads'
export { Postpone } from './rsc/postpone'
@@ -223,16 +223,17 @@ describe('Error overlay for hydration errors in App router', () => {
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<script>
<script>
<script>
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
<Root params={Promise}>
<html
- className="server-html"
>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<script>
<script>
<script>
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
<Root params={Promise}>
<html
- className="server-html"
>
...",
"description": "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:",
"environmentLabel": null,
@@ -259,13 +260,14 @@ describe('Error overlay for hydration errors in App router', () => {
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
<Root params={Promise}>
<html
- className="server-html"
>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
<Root params={Promise}>
<html
- className="server-html"
>
...",
"description": "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:",
"environmentLabel": null,
@@ -1099,15 +1101,16 @@ describe('Error overlay for hydration errors in App router', () => {
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<script>
<script>
<Layout>
> <html>
<body>
<Script src="https://ex..." strategy="beforeInte...">
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<script>
<script>
<Layout>
> <html>
<body>
<Script src="https://ex..." strategy="beforeInte...">
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
...",
"description": "In HTML, <script> cannot be a child of <html>.
This will cause a hydration error.",
@@ -1161,13 +1164,14 @@ describe('Error overlay for hydration errors in App router', () => {
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<Layout>
> <html>
<body>
<Script src="https://ex..." strategy="beforeInte...">
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.js">
<SegmentTrieNode>
<Layout>
> <html>
<body>
<Script src="https://ex..." strategy="beforeInte...">
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
...",
"description": "In HTML, <script> cannot be a child of <html>.
This will cause a hydration error.",
@@ -0,0 +1,10 @@
import { headers } from 'next/headers'
export default async function DynamicWrapper({
children,
}: {
children: React.ReactNode
}) {
await headers()
return <div>{children}</div>
}
@@ -0,0 +1,21 @@
import { Suspense } from 'react'
import DynamicWrapper from './dynamic-wrapper'
import { SimpleWrapper } from './simple-wrapper'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<SimpleWrapper>
<Suspense>
<html>
<body>
<DynamicWrapper>{children}</DynamicWrapper>
</body>
</html>
</Suspense>
</SimpleWrapper>
)
}
@@ -0,0 +1,5 @@
import React from 'react'
export default function HomePage() {
return <p>Hello World</p>
}
@@ -0,0 +1,5 @@
'use client'
export const SimpleWrapper = ({ children }: { children: React.ReactNode }) => {
return children
}
@@ -0,0 +1,11 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
cacheComponents: true,
enablePrerenderSourceMaps: false,
},
}
module.exports = nextConfig
@@ -0,0 +1,33 @@
import { nextTestSetup } from 'e2e-utils'
describe('Root Suspense Dynamic Rendering', () => {
const { next, isNextStart } = nextTestSetup({
files: __dirname + '/fixtures/default',
skipDeployment: true,
})
// TODO: remove when there is a test for isNextDev === false
it('placeholder to satisfy at least one test when isNextDev is false', async () => {
expect(true).toBe(true)
})
if (isNextStart) {
it('should handle dynamic content wrapped in Suspense above HTML structure', async () => {
try {
// Should render the page successfully
const $ = await next.render$('/')
expect($('body').text()).toContain('Hello World')
} catch (error) {
throw new Error(
'Expected build to succeed for Suspense wrapping dynamic content above HTML',
{ cause: error }
)
}
})
it('should correctly mark route as dynamic', async () => {
// The route should be marked as dynamic (ƒ) not static (○)
expect(next.cliOutput).toContain('ƒ /')
})
}
})