mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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>
|
||||
)
|
||||
|
||||
+17
-13
@@ -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
@@ -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__'
|
||||
@@ -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('ƒ /')
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user