Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,31 @@ import { isInstantValidationError } from './instant-validation/instant-validatio
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
import { RENDER_STAGES_BY_DATA_KIND } from '../dynamic-rendering-utils'

/**
* Determines whether streaming metadata should be served for this render.
*
* The metadata component tree has a different shape depending on whether
* streaming metadata is enabled (see `createMetadataComponents`). Prerenders
* always render with streaming metadata enabled (`serveStreamingMetadata` is
* set to `true` during build/export), so a render that resumes a postponed
* prerender must also render with it enabled — regardless of the requesting
* user agent — or React will detect a tree mismatch ("Expected the resume to
* render <div> in this slot but instead it rendered
* <__next_metadata_boundary__>") and fall back to client rendering.
*
* The user-agent based opt-out (HTML-limited bots receive blocking metadata)
* only applies to renders that produce the whole document in a single pass.
*/
function getServeStreamingMetadata(
renderOpts: Pick<RenderOpts, 'serveStreamingMetadata' | 'postponed'>
): boolean {
if (typeof renderOpts.postponed === 'string') {
return true
}

return !!renderOpts.serveStreamingMetadata
}

export type GetDynamicParamFromSegment = (
// The LoaderTree to extract the dynamic param from
loaderTree: LoaderTree
Expand Down Expand Up @@ -632,7 +657,7 @@ async function generateDynamicRSCPayload(
url,
} = ctx

const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const serveStreamingMetadata = getServeStreamingMetadata(ctx.renderOpts)

if (!options?.skipPageRendering) {
const preloadCallbacks: PreloadCallbacks = []
Expand Down Expand Up @@ -1960,7 +1985,7 @@ async function getRSCPayload(
getDynamicParamFromSegment,
query
)
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const serveStreamingMetadata = getServeStreamingMetadata(ctx.renderOpts)
const hasGlobalNotFound = !!tree[2]['global-not-found']

const metadataIsRuntimePrefetchable =
Expand Down Expand Up @@ -2115,7 +2140,7 @@ async function getErrorRSCPayload(
let Viewport: ComponentType | null = null
let Metadata: ComponentType | null = null
if (shouldRenderMetadataAndViewport) {
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const serveStreamingMetadata = getServeStreamingMetadata(ctx.renderOpts)
const metadataIsRuntimePrefetchable =
await anySegmentHasRuntimePrefetchEnabled(tree)
const metadataComponents = createMetadataComponents({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <p>loading...</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { cookies } from 'next/headers'
import { connection } from 'next/server'

export async function generateMetadata() {
// Dynamic metadata: resolved during the resume, not the prerender.
await connection()

return {
title: 'dynamic-metadata-title',
}
}

export default async function Page() {
// Dynamic at the top level of the page (no Suspense boundary): the whole
// segment, including the metadata slot rendered adjacent to it, is part of
// the postponed hole instead of the static shell.
const store = await cookies()

return (
<main>
<p id="content">dynamic content {store.size}</p>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Layout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import fs from 'node:fs/promises'
import { join } from 'node:path'
import { ChildProcess } from 'node:child_process'
import { FileRef, nextTestSetup } from 'e2e-utils'
import {
fetchViaHTTP,
findPort,
initNextServerScript,
killApp,
withInvocationId,
} from 'next-test-utils'

const GOOGLEBOT_UA =
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'

const TWITTERBOT_UA = 'Twitterbot/1.0'

// Regression test for the PPR resume metadata tree mismatch: a resume must
// render the same metadata tree shape as the prerender that postponed it,
// regardless of the requesting user agent. Previously, bot user agents forced
// `serveStreamingMetadata` to `false` during the resume while the prerender
// rendered with it enabled, causing:
// "Expected the resume to render <div> in this slot but instead it
// rendered <__next_metadata_boundary__>."
// and a fallback to client rendering.
describe('ppr-resume-bot-metadata', () => {
let server: ChildProcess
let appPort: number | string
let dynamicPostpone: string
let cliOutput = ''

beforeAll(() => {
process.env.NOW_BUILDER = '1'
process.env.NEXT_PRIVATE_TEST_HEADERS = '1'
})

const { next } = nextTestSetup({
files: {
app: new FileRef(join(__dirname, 'app')),
},
nextConfig: {
cacheComponents: true,
output: 'standalone',
},
})

beforeAll(async () => {
// Stop the server, we're going to restart it using the standalone server
// in minimal mode below.
await next.stop()

// Read the postponed state that was generated at build time.
dynamicPostpone = (await next.readJSON('.next/server/app/dynamic.meta'))
.postponed

if (typeof dynamicPostpone !== 'string') {
throw new Error(
'invariant: expected the build to generate postponed state for /dynamic'
)
}

await fs.rename(
join(next.testDir, '.next/standalone'),
join(next.testDir, 'standalone')
)

const serverFilePath = join(next.testDir, 'standalone/server.js')

// We're going to use the minimal mode for the server, which is how the
// server runs when deployed: the platform serves the static shell from
// its cache and invokes the server only to resume the postponed state.
await fs.writeFile(
serverFilePath,
(await fs.readFile(serverFilePath, 'utf8')).replace(
'port:',
`minimalMode: true, port:`
)
)

appPort = await findPort()

server = await initNextServerScript(
serverFilePath,
/- Local:/,
{
...process.env,
...next.env,
__NEXT_TEST_MODE: 'e2e',
PORT: `${appPort}`,
},
undefined,
{
cwd: next.testDir,
onStderr(data) {
cliOutput += data
},
onStdout(data) {
cliOutput += data
},
}
)
})

afterAll(async () => {
delete process.env.NOW_BUILDER
delete process.env.NEXT_PRIVATE_TEST_HEADERS
if (server) await killApp(server)
})

it.each([
['a browser', undefined],
// DOM bots (resolves `getBotType()` to 'dom').
['Googlebot', GOOGLEBOT_UA],
// HTML-limited bots (resolves `getBotType()` to 'html').
['Twitterbot', TWITTERBOT_UA],
])(
'should resume without a metadata tree mismatch for %s',
async (_label, userAgent) => {
const before = cliOutput.length

const res = await fetchViaHTTP(
appPort,
'/dynamic',
undefined,
withInvocationId({
headers: {
'x-matched-path': '/dynamic',
'next-resume': '1',
...(userAgent ? { 'user-agent': userAgent } : {}),
},
method: 'POST',
body: dynamicPostpone,
})
)

expect(res.status).toBe(200)

const html = await res.text()

// The dynamic content must be rendered into the resumed HTML rather
// than deferred to client rendering.
expect(html).toContain('dynamic content')
expect(html).toContain('dynamic-metadata-title')

// React must not have bailed out of the resume.
expect(cliOutput.slice(before)).not.toContain(
'Expected the resume to render'
)
}
)
})