Skip to content
12 changes: 8 additions & 4 deletions packages/next/src/build/templates/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,17 @@ export async function handler(
// being true for a revalidate due to modifying the base-server this.renderOpts
// when fixing this to correct logic it causes hydration issue since we set
// serveStreamingMetadata to true during export
let serveStreamingMetadata = !userAgent
? true
: shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)
// TEST: Hardcode serveStreamingMetadata to true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serveStreamingMetadata logic has been hardcoded to true, bypassing user agent detection and bot handling, which could cause incorrect behavior for different client types.

View Details
📝 Patch Details
diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts
index 53548e2533..9d4a74b86f 100644
--- a/packages/next/src/build/templates/app-page.ts
+++ b/packages/next/src/build/templates/app-page.ts
@@ -268,17 +268,13 @@ export async function handler(
   // being true for a revalidate due to modifying the base-server this.renderOpts
   // when fixing this to correct logic it causes hydration issue since we set
   // serveStreamingMetadata to true during export
-  // TEST: Hardcode serveStreamingMetadata to true
-  let serveStreamingMetadata = true
-  // Original logic:
-  // let serveStreamingMetadata = !userAgent
-  //   ? true
-  //   : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)
+  let serveStreamingMetadata = !userAgent
+    ? true
+    : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)
 
   if (isHtmlBot && isRoutePPREnabled) {
     isSSG = false
-    // TEST: Don't set serveStreamingMetadata to false
-    // serveStreamingMetadata = false
+    serveStreamingMetadata = false
   }
 
   // In development, we always want to generate dynamic HTML.

Analysis

The code has hardcoded serveStreamingMetadata = true and commented out the original logic that determined this value based on user agent detection and bot handling:

// Original logic (commented out):
// let serveStreamingMetadata = !userAgent
//   ? true
//   : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)

This bypasses important logic that:

  1. Detects bots and serves them appropriate content
  2. Respects the htmlLimitedBots configuration
  3. Handles cases where user agents should receive different streaming behavior

Additionally, the bot-specific logic serveStreamingMetadata = false for HTML bots with PPR is also commented out, which could cause bots to receive streaming content when they should receive static content.


Recommendation

Restore the original user agent detection logic and bot handling. If there are specific issues with the existing logic, they should be fixed properly rather than bypassed with hardcoded values. The changes should respect the existing API contracts and configuration options.

let serveStreamingMetadata = true
// Original logic:
// let serveStreamingMetadata = !userAgent
// ? true
// : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)

if (isHtmlBot && isRoutePPREnabled) {
isSSG = false
serveStreamingMetadata = false
// TEST: Don't set serveStreamingMetadata to false
// serveStreamingMetadata = false
}

// In development, we always want to generate dynamic HTML.
Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,13 @@ function Router({
const [headCacheNode, headKey] = matchingHead
head = <Head key={headKey} headCacheNode={headCacheNode} />
} else {
head = null
// Always render a consistent placeholder to maintain tree structure during PPR
head = <>{null}</>
}

let content = (
<RedirectBoundary>
{head}
{/* {head} <- rule out mismatch from here */}
{/* 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. */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
*/

"use strict";

console.log('file override react-dom-server.node.production.js 111')

var util = require("util"),
crypto = require("crypto"),
async_hooks = require("async_hooks"),
Expand Down Expand Up @@ -5792,7 +5795,15 @@ function retryNode(request, task) {
0 < task.replay.nodes.length
)
throw Error(
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering."
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering. Debug info: " + JSON.stringify({
component: getComponentNameFromType(type) || "Unknown",
props: Object.keys(props || {}),
keyPath: task.keyPath,
childIndex: task.childIndex,
replayNodes: task.replay.nodes,
replaySlots: task.replay.slots,
pendingTasks: task.replay.pendingTasks
})
);
task.replay.pendingTasks--;
} catch (x) {
Expand Down Expand Up @@ -5883,7 +5894,15 @@ function retryNode(request, task) {
0 < task.replay.nodes.length
)
throw Error(
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering."
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering. Debug info: " + JSON.stringify({
boundaryID: props.rootSegmentID || "Unknown",
content: typeof content === "string" ? content.substring(0, 100) : typeof content,
keyPath: task.keyPath,
childIndex: task.childIndex,
replayNodes: task.replay.nodes,
replaySlots: task.replay.slots,
pendingTasks: task.replay.pendingTasks
})
);
task.replay.pendingTasks--;
if (0 === props.pendingTasks && 0 === props.status) {
Expand Down Expand Up @@ -6064,7 +6083,15 @@ function renderChildrenArray(request, task, children, childIndex) {
renderChildrenArray(request, task, children, -1);
if (1 === task.replay.pendingTasks && 0 < task.replay.nodes.length)
throw Error(
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering."
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering. Debug info: " + JSON.stringify({
arrayIndex: childIndex,
childrenCount: Array.isArray(children) ? children.length : "not array",
keyPath: task.keyPath,
childIndex: task.childIndex,
replayNodes: task.replay.nodes,
replaySlots: task.replay.slots,
pendingTasks: task.replay.pendingTasks
})
);
task.replay.pendingTasks--;
} catch (x) {
Expand Down Expand Up @@ -6829,7 +6856,14 @@ function performWork(request$jscomp$1) {
0 < task.replay.nodes.length
)
throw Error(
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering."
"Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering. Debug info: " + JSON.stringify({
taskNode: task.node || "Unknown",
keyPath: task.keyPath,
childIndex: task.childIndex,
replayNodes: task.replay.nodes,
replaySlots: task.replay.slots,
pendingTasks: task.replay.pendingTasks
})
);
task.replay.pendingTasks--;
task.abortSet.delete(task);
Expand Down
21 changes: 14 additions & 7 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
METADATA_BOUNDARY_NAME,
VIEWPORT_BOUNDARY_NAME,
} from '../framework/boundary-constants'
import { AsyncMetadataOutlet } from '../../client/components/metadata/async-metadata'
// import { AsyncMetadataOutlet } from '../../client/components/metadata/async-metadata'
import { isPostpone } from '../../server/lib/router-utils/is-postpone'
import { createServerSearchParamsForMetadata } from '../../server/request/search-params'
import { createServerPathnameForMetadata } from '../../server/request/pathname'
Expand Down Expand Up @@ -80,6 +80,9 @@ export function createMetadataComponents({
getViewportReady: () => Promise<void>
StreamingMetadataOutlet: React.ComponentType | null
} {
// TEST: Hardcode serveStreamingMetadata to true to rule out edge case
serveStreamingMetadata = true

const searchParams = createServerSearchParamsForMetadata(
parsedQuery,
workStore
Expand Down Expand Up @@ -243,13 +246,17 @@ export function createMetadataComponents({
return undefined
}

function StreamingMetadataOutletImpl() {
return <AsyncMetadataOutlet promise={resolveFinalMetadata()} />
}
// function StreamingMetadataOutletImpl() {
// return <AsyncMetadataOutlet promise={resolveFinalMetadata()} />
// }

const StreamingMetadataOutlet = serveStreamingMetadata
? StreamingMetadataOutletImpl
: null
// HACK: For PPR routes, always use null to ensure consistency between prerender and resume
// This prevents "Couldn't find all resumable slots" errors when the tree structure differs
const StreamingMetadataOutlet = null
// Original logic (commented out for debugging):
// const StreamingMetadataOutlet = serveStreamingMetadata
// ? StreamingMetadataOutletImpl
// : null

return {
ViewportTree,
Expand Down
12 changes: 9 additions & 3 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,9 @@ async function generateDynamicRSCPayload(
url,
} = ctx

const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
// TEST: Hardcode serveStreamingMetadata to true
const serveStreamingMetadata = true
// Original: !!ctx.renderOpts.serveStreamingMetadata

if (!options?.skipFlight) {
const preloadCallbacks: PreloadCallbacks = []
Expand Down Expand Up @@ -1067,7 +1069,9 @@ async function getRSCPayload(
getDynamicParamFromSegment,
query
)
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
// TEST: Hardcode serveStreamingMetadata to true
const serveStreamingMetadata = true
// Original: !!ctx.renderOpts.serveStreamingMetadata
const hasGlobalNotFound = !!tree[2]['global-not-found']

const {
Expand Down Expand Up @@ -1200,7 +1204,9 @@ async function getErrorRSCPayload(
workStore,
} = ctx

const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
// TEST: Hardcode serveStreamingMetadata to true
const serveStreamingMetadata = true
// Original: !!ctx.renderOpts.serveStreamingMetadata
const { MetadataTree, ViewportTree } = createMetadataComponents({
tree,
parsedQuery: query,
Expand Down
20 changes: 15 additions & 5 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,11 +420,13 @@ async function createComponentTreeInternal(
? process.env.__NEXT_EDGE_PROJECT_DIR
: ctx.renderOpts.dir) || ''

// Use the same condition to render metadataOutlet as metadata
const metadataOutlet = StreamingMetadataOutlet ? (
<StreamingMetadataOutlet />
) : (
<MetadataOutlet ready={getMetadataReady} />
// StreamingMetadataOutlet is now always null from createMetadataComponents
// to ensure consistency between prerender and resume for PPR routes
const metadataOutlet = (
<MetadataOutlet
ready={getMetadataReady}
StreamingComponent={StreamingMetadataOutlet}
/>
)

const [notFoundElement, notFoundFilePath] =
Expand Down Expand Up @@ -823,10 +825,12 @@ async function createComponentTreeInternal(
<React.Fragment key={cacheNodeKey}>
{wrappedPageElement}
{layerAssets}
{/* TEST: Commenting out metadata outlets to verify error source
<OutletBoundary>
<MetadataOutlet ready={getViewportReady} />
{metadataOutlet}
</OutletBoundary>
*/}
Comment on lines +828 to +833
Copy link
Contributor

@vercel vercel bot Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{/* TEST: Commenting out metadata outlets to verify error source
<OutletBoundary>
<MetadataOutlet ready={getViewportReady} />
{metadataOutlet}
</OutletBoundary>
*/}

The metadata outlet error boundaries have been completely commented out, which means metadata-related errors will not be properly caught and handled during rendering.

View Details

Analysis

The code has commented out the entire OutletBoundary section that wraps metadata outlets:

{/* TEST: Commenting out metadata outlets to verify error source
<OutletBoundary>
  <MetadataOutlet ready={getViewportReady} />
  {metadataOutlet}
</OutletBoundary>
*/}

This removes critical error handling for metadata operations. The OutletBoundary component is responsible for catching and handling errors that occur during metadata resolution and rendering. Without this boundary:

  1. Metadata errors will bubble up and potentially crash the entire page render
  2. Users will see unhandled errors instead of graceful fallbacks
  3. The application loses its ability to recover from metadata-related failures
  4. Debugging becomes more difficult as errors lose their proper context

Recommendation

Restore the OutletBoundary wrapper around the metadata outlets. If there are specific issues with metadata rendering causing the "resumable slots" error, those should be fixed within the metadata system rather than by removing error boundaries entirely. The error boundaries are essential for application stability and should not be commented out.

</React.Fragment>,
parallelRouteCacheNodeSeedData,
loadingData,
Expand Down Expand Up @@ -1014,9 +1018,15 @@ async function createComponentTreeInternal(

async function MetadataOutlet({
ready,
StreamingComponent,
}: {
ready: () => Promise<void> & { status?: string; value?: unknown }
StreamingComponent?: React.ComponentType | null
}) {
if (StreamingComponent) {
return <StreamingComponent />
}

const r = ready()
// We can avoid a extra microtask by unwrapping the instrumented promise directly if available.
if (r.status === 'rejected') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return <div>Content</div>
}
14 changes: 14 additions & 0 deletions test/e2e/app-dir/resume-streaming-metadata/app/@content/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Suspense } from 'react'

async function AsyncComponent() {
await new Promise((resolve) => setTimeout(resolve, 100))
return <div>Async content {Math.random()}</div>
}

export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { headers } from 'next/headers'
import { Metadata } from 'next'
import { unstable_noStore } from 'next/cache'

async function getChatIdFromHeaders() {
const headersList = await headers()
return headersList.get('x-chat-id') || 'default-id'
}

export async function generateMetadata(): Promise<Metadata> {
const chatId = await getChatIdFromHeaders()

// Simulate async DB call like v0
await new Promise((resolve) => setTimeout(resolve, 100))

return {
title: `Chat ${chatId}`,
description: `Chat session ${chatId}`,
}
}

export default async function ChatPage() {
unstable_noStore()

const chatId = await getChatIdFromHeaders()

if (!chatId) {
return <div>No chat ID</div>
}

// Simulate some async work
await new Promise((resolve) => setTimeout(resolve, 50))
const random = Math.random()

return (
<div>
<h1>Chat {chatId}</h1>
<p>Random: {random}</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Suspense } from 'react'

export default async function Layout({
children,
content,
}: {
children: React.ReactNode
content: React.ReactNode
params: Promise<{ variants: string }>
}) {
return (
<>
{children}
<Suspense>{content}</Suspense>
</>
)
}
22 changes: 22 additions & 0 deletions test/e2e/app-dir/resume-streaming-metadata/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ReactNode, Suspense } from 'react'
export default function Root({
children,
sidebar,
content,
}: {
children: ReactNode
sidebar: ReactNode
content: ReactNode
}) {
return (
<html>
<body>
<Suspense>
{sidebar}
{children}
{content}
</Suspense>
</body>
</html>
)
}
19 changes: 19 additions & 0 deletions test/e2e/app-dir/resume-streaming-metadata/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ id: string | undefined }>
}) {
const { id } = await searchParams
const random = Math.random()
return (
<p>
hello world {random} {id}
</p>
)
}

export const generateMetadata = async () => {
return {
title: 'Hello World',
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default async function Slot1() {
await new Promise((resolve) => setTimeout(resolve, 10))
return <div>Slot 1</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default async function Slot2() {
await new Promise((resolve) => setTimeout(resolve, 20))
return <div>Slot 2</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default async function Slot3() {
await new Promise((resolve) => setTimeout(resolve, 30))
return <div>Slot 3</div>
}
Loading
Loading