Skip to content

Commit cc5e9e6

Browse files
authored
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.
1 parent 4dc3410 commit cc5e9e6

File tree

14 files changed

+180
-56
lines changed

14 files changed

+180
-56
lines changed

packages/next/src/client/components/app-router.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { isRedirectError, RedirectType } from './redirect-error'
4444
import { pingVisibleLinks } from './links'
4545
import RootErrorBoundary from './errors/root-error-boundary'
4646
import DefaultGlobalError from './builtin/global-error'
47+
import { RootLayoutBoundary } from '../../lib/framework/boundary-components'
4748

4849
const globalMutable: {
4950
pendingMpaPath?: string
@@ -482,7 +483,10 @@ function Router({
482483
let content = (
483484
<RedirectBoundary>
484485
{head}
485-
{cache.rsc}
486+
{/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout.
487+
When users wrap their layout in <Suspense>, this creates the component stack pattern
488+
"Suspense -> RootLayoutBoundary" which dynamic-rendering.ts uses to allow dynamic rendering. */}
489+
<RootLayoutBoundary>{cache.rsc}</RootLayoutBoundary>
486490
<AppRouterAnnouncer tree={tree} />
487491
</RedirectBoundary>
488492
)
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
11
'use client'
22

3+
import type { ReactNode } from 'react'
34
import {
45
METADATA_BOUNDARY_NAME,
56
VIEWPORT_BOUNDARY_NAME,
67
OUTLET_BOUNDARY_NAME,
7-
} from '../../../lib/metadata/metadata-constants'
8+
ROOT_LAYOUT_BOUNDARY_NAME,
9+
} from './boundary-constants'
810

911
// We use a namespace object to allow us to recover the name of the function
1012
// at runtime even when production bundling/minification is used.
1113
const NameSpace = {
12-
[METADATA_BOUNDARY_NAME]: function ({
13-
children,
14-
}: {
15-
children: React.ReactNode
16-
}) {
14+
[METADATA_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
1715
return children
1816
},
19-
[VIEWPORT_BOUNDARY_NAME]: function ({
20-
children,
21-
}: {
22-
children: React.ReactNode
23-
}) {
17+
[VIEWPORT_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
2418
return children
2519
},
26-
[OUTLET_BOUNDARY_NAME]: function ({
20+
[OUTLET_BOUNDARY_NAME]: function ({ children }: { children: ReactNode }) {
21+
return children
22+
},
23+
[ROOT_LAYOUT_BOUNDARY_NAME]: function ({
2724
children,
2825
}: {
29-
children: React.ReactNode
26+
children: ReactNode
3027
}) {
3128
return children
3229
},
@@ -46,3 +43,10 @@ export const OutletBoundary =
4643
// We use slice(0) to trick the bundler into not inlining/minifying the function
4744
// so it retains the name inferred from the namespace object
4845
NameSpace[OUTLET_BOUNDARY_NAME.slice(0) as typeof OUTLET_BOUNDARY_NAME]
46+
47+
export const RootLayoutBoundary =
48+
// We use slice(0) to trick the bundler into not inlining/minifying the function
49+
// so it retains the name inferred from the namespace object
50+
NameSpace[
51+
ROOT_LAYOUT_BOUNDARY_NAME.slice(0) as typeof ROOT_LAYOUT_BOUNDARY_NAME
52+
]
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const METADATA_BOUNDARY_NAME = '__next_metadata_boundary__'
22
export const VIEWPORT_BOUNDARY_NAME = '__next_viewport_boundary__'
33
export const OUTLET_BOUNDARY_NAME = '__next_outlet_boundary__'
4+
export const ROOT_LAYOUT_BOUNDARY_NAME = '__next_root_layout_boundary__'

packages/next/src/lib/metadata/metadata.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import type { WorkStore } from '../../server/app-render/work-async-storage.exter
3737
import {
3838
METADATA_BOUNDARY_NAME,
3939
VIEWPORT_BOUNDARY_NAME,
40-
} from './metadata-constants'
40+
} from '../framework/boundary-constants'
4141
import { AsyncMetadataOutlet } from '../../client/components/metadata/async-metadata'
4242
import { isPostpone } from '../../server/lib/router-utils/is-postpone'
4343
import { createServerSearchParamsForMetadata } from '../../server/request/search-params'

packages/next/src/server/app-render/create-component-tree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { StaticGenBailoutError } from '../../client/components/static-generation
2020
import type { LoadingModuleData } from '../../shared/lib/app-router-context.shared-runtime'
2121
import type { Params } from '../request/params'
2222
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
23-
import { OUTLET_BOUNDARY_NAME } from '../../lib/metadata/metadata-constants'
23+
import { OUTLET_BOUNDARY_NAME } from '../../lib/framework/boundary-constants'
2424
import type {
2525
UseCacheLayoutComponentProps,
2626
UseCachePageComponentProps,

packages/next/src/server/app-render/dynamic-rendering.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import {
4040
METADATA_BOUNDARY_NAME,
4141
VIEWPORT_BOUNDARY_NAME,
4242
OUTLET_BOUNDARY_NAME,
43-
} from '../../lib/metadata/metadata-constants'
43+
ROOT_LAYOUT_BOUNDARY_NAME,
44+
} from '../../lib/framework/boundary-constants'
4445
import { scheduleOnNextTick } from '../../lib/scheduler'
4546
import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
4647
import { InvariantError } from '../../shared/lib/invariant-error'
@@ -637,8 +638,27 @@ export function useDynamicRouteParams(expression: string) {
637638
}
638639

639640
const hasSuspenseRegex = /\n\s+at Suspense \(<anonymous>\)/
640-
const hasSuspenseAfterBodyOrHtmlRegex =
641-
/\n\s+at (?:body|html) \(<anonymous>\)[\s\S]*?\n\s+at Suspense \(<anonymous>\)/
641+
642+
// Common implicit body tags that React will treat as body when placed directly in html
643+
const bodyAndImplicitTags =
644+
'body|div|main|section|article|aside|header|footer|nav|form|p|span|h1|h2|h3|h4|h5|h6'
645+
646+
// Detects when RootLayoutBoundary (our framework marker component) appears
647+
// after Suspense in the component stack, indicating the root layout is wrapped
648+
// within a Suspense boundary. Ensures no body/html/implicit-body components are in between.
649+
//
650+
// Example matches:
651+
// at Suspense (<anonymous>)
652+
// at __next_root_layout_boundary__ (<anonymous>)
653+
//
654+
// Or with other components in between (but not body/html/implicit-body):
655+
// at Suspense (<anonymous>)
656+
// at SomeComponent (<anonymous>)
657+
// at __next_root_layout_boundary__ (<anonymous>)
658+
const hasSuspenseBeforeRootLayoutWithoutBodyOrImplicitBodyRegex = new RegExp(
659+
`\\n\\s+at Suspense \\(<anonymous>\\)(?:(?!\\n\\s+at (?:${bodyAndImplicitTags}) \\(<anonymous>\\))[\\s\\S])*?\\n\\s+at ${ROOT_LAYOUT_BOUNDARY_NAME} \\([^\\n]*\\)`
660+
)
661+
642662
const hasMetadataRegex = new RegExp(
643663
`\\n\\s+at ${METADATA_BOUNDARY_NAME}[\\n\\s]`
644664
)
@@ -662,9 +682,14 @@ export function trackAllowedDynamicAccess(
662682
} else if (hasViewportRegex.test(componentStack)) {
663683
dynamicValidation.hasDynamicViewport = true
664684
return
665-
} else if (hasSuspenseAfterBodyOrHtmlRegex.test(componentStack)) {
666-
// This prerender has a Suspense boundary above the body which
667-
// effectively opts the page into allowing 100% dynamic rendering
685+
} else if (
686+
hasSuspenseBeforeRootLayoutWithoutBodyOrImplicitBodyRegex.test(
687+
componentStack
688+
)
689+
) {
690+
// For Suspense within body, the prelude wouldn't be empty so it wouldn't violate the empty static shells rule.
691+
// But if you have Suspense above body, the prelude is empty but we allow that because having Suspense
692+
// is an explicit signal from the user that they acknowledge the empty shell and want dynamic rendering.
668693
dynamicValidation.hasAllowedDynamic = true
669694
dynamicValidation.hasSuspenseAboveBody = true
670695
return

packages/next/src/server/app-render/entry-base.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export {
3636
MetadataBoundary,
3737
ViewportBoundary,
3838
OutletBoundary,
39-
} from '../../client/components/metadata/metadata-boundary'
39+
RootLayoutBoundary,
40+
} from '../../lib/framework/boundary-components'
4041

4142
export { preloadStyle, preloadFont, preconnect } from './rsc/preloads'
4243
export { Postpone } from './rsc/postpone'

test/development/acceptance-app/hydration-error.test.ts

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,17 @@ describe('Error overlay for hydration errors in App router', () => {
223223
<RedirectBoundary>
224224
<RedirectErrorBoundary router={{...}}>
225225
<Head>
226-
<SegmentViewNode type="layout" pagePath="layout.js">
227-
<SegmentTrieNode>
228-
<script>
229-
<script>
230-
<script>
231-
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
232-
<Root params={Promise}>
233-
<html
234-
- className="server-html"
235-
>
226+
<__next_root_layout_boundary__>
227+
<SegmentViewNode type="layout" pagePath="layout.js">
228+
<SegmentTrieNode>
229+
<script>
230+
<script>
231+
<script>
232+
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
233+
<Root params={Promise}>
234+
<html
235+
- className="server-html"
236+
>
236237
...",
237238
"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:",
238239
"environmentLabel": null,
@@ -259,13 +260,14 @@ describe('Error overlay for hydration errors in App router', () => {
259260
<RedirectBoundary>
260261
<RedirectErrorBoundary router={{...}}>
261262
<Head>
262-
<SegmentViewNode type="layout" pagePath="layout.js">
263-
<SegmentTrieNode>
264-
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
265-
<Root params={Promise}>
266-
<html
267-
- className="server-html"
268-
>
263+
<__next_root_layout_boundary__>
264+
<SegmentViewNode type="layout" pagePath="layout.js">
265+
<SegmentTrieNode>
266+
<ClientSegmentRoot Component={function Root} slots={{...}} params={{}}>
267+
<Root params={Promise}>
268+
<html
269+
- className="server-html"
270+
>
269271
...",
270272
"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:",
271273
"environmentLabel": null,
@@ -1099,15 +1101,16 @@ describe('Error overlay for hydration errors in App router', () => {
10991101
<RedirectBoundary>
11001102
<RedirectErrorBoundary router={{...}}>
11011103
<Head>
1102-
<SegmentViewNode type="layout" pagePath="layout.js">
1103-
<SegmentTrieNode>
1104-
<script>
1105-
<script>
1106-
<Layout>
1107-
> <html>
1108-
<body>
1109-
<Script src="https://ex..." strategy="beforeInte...">
1110-
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
1104+
<__next_root_layout_boundary__>
1105+
<SegmentViewNode type="layout" pagePath="layout.js">
1106+
<SegmentTrieNode>
1107+
<script>
1108+
<script>
1109+
<Layout>
1110+
> <html>
1111+
<body>
1112+
<Script src="https://ex..." strategy="beforeInte...">
1113+
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
11111114
...",
11121115
"description": "In HTML, <script> cannot be a child of <html>.
11131116
This will cause a hydration error.",
@@ -1161,13 +1164,14 @@ describe('Error overlay for hydration errors in App router', () => {
11611164
<RedirectBoundary>
11621165
<RedirectErrorBoundary router={{...}}>
11631166
<Head>
1164-
<SegmentViewNode type="layout" pagePath="layout.js">
1165-
<SegmentTrieNode>
1166-
<Layout>
1167-
> <html>
1168-
<body>
1169-
<Script src="https://ex..." strategy="beforeInte...">
1170-
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
1167+
<__next_root_layout_boundary__>
1168+
<SegmentViewNode type="layout" pagePath="layout.js">
1169+
<SegmentTrieNode>
1170+
<Layout>
1171+
> <html>
1172+
<body>
1173+
<Script src="https://ex..." strategy="beforeInte...">
1174+
> <script nonce={undefined} dangerouslySetInnerHTML={{__html:"(self.__ne..."}}>
11711175
...",
11721176
"description": "In HTML, <script> cannot be a child of <html>.
11731177
This will cause a hydration error.",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { headers } from 'next/headers'
2+
3+
export default async function DynamicWrapper({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
await headers()
9+
return <div>{children}</div>
10+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Suspense } from 'react'
2+
import DynamicWrapper from './dynamic-wrapper'
3+
import { SimpleWrapper } from './simple-wrapper'
4+
5+
export default function RootLayout({
6+
children,
7+
}: {
8+
children: React.ReactNode
9+
}) {
10+
return (
11+
<SimpleWrapper>
12+
<Suspense>
13+
<html>
14+
<body>
15+
<DynamicWrapper>{children}</DynamicWrapper>
16+
</body>
17+
</html>
18+
</Suspense>
19+
</SimpleWrapper>
20+
)
21+
}

0 commit comments

Comments
 (0)