From 3981ec357d217e8eb1d00dd8fd2786ffbd866ce1 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 11 Aug 2025 14:56:12 -0400 Subject: [PATCH 01/11] Keep metadata outlet consistent in prerender and resume --- .../server/app-render/create-component-tree.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index fe579cc870492..843ecebc93fdf 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -420,11 +420,11 @@ async function createComponentTreeInternal( ? process.env.__NEXT_EDGE_PROJECT_DIR : ctx.renderOpts.dir) || '' - // Use the same condition to render metadataOutlet as metadata - const metadataOutlet = StreamingMetadataOutlet ? ( - - ) : ( - + const metadataOutlet = ( + ) const [notFoundElement, notFoundFilePath] = @@ -1014,9 +1014,15 @@ async function createComponentTreeInternal( async function MetadataOutlet({ ready, + StreamingComponent, }: { ready: () => Promise & { status?: string; value?: unknown } + StreamingComponent?: React.ComponentType | null }) { + if (StreamingComponent) { + return + } + const r = ready() // We can avoid a extra microtask by unwrapping the instrumented promise directly if available. if (r.status === 'rejected') { From a506827091c6962060cede6815823e51b2e22042 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 11 Aug 2025 15:30:29 -0400 Subject: [PATCH 02/11] add detailed debugging for resumable slots error --- .../cjs/react-dom-server.node.production.js | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js b/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js index 0317b8053fa56..1b3dcfe028e8b 100644 --- a/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js +++ b/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js @@ -9,6 +9,9 @@ */ "use strict"; + +console.log('file override react-dom-server.node.production.js') + var util = require("util"), crypto = require("crypto"), async_hooks = require("async_hooks"), @@ -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) { @@ -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) { @@ -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) { @@ -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); From d223dd8c9bb80640b70972ee8a38375c6cb6edec Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 11 Aug 2025 21:25:21 -0400 Subject: [PATCH 03/11] rule out StreamingMetadataOutlet variable --- .../app-render/create-component-tree.tsx | 7 ++- .../app/@content/default.tsx | 3 ++ .../app/@content/page.tsx | 14 ++++++ .../app/@sidebar/default.tsx | 3 ++ .../(app)/@content/(chat)/id/page.tsx | 41 +++++++++++++++++ .../(dynamic-root)/(app)/@content/default.tsx | 3 ++ .../(dynamic-root)/(app)/default.tsx | 3 ++ .../(dynamic-root)/(app)/layout.tsx | 17 +++++++ .../resume-streaming-metadata/app/layout.tsx | 22 ++++++++++ .../resume-streaming-metadata/app/page.tsx | 19 ++++++++ .../app/shift/@slot1/page.tsx | 4 ++ .../app/shift/@slot2/page.tsx | 4 ++ .../app/shift/@slot3/page.tsx | 4 ++ .../app/shift/layout.tsx | 24 ++++++++++ .../app/shift/page.tsx | 29 ++++++++++++ .../app/test/page.tsx | 44 +++++++++++++++++++ .../resume-streaming-metadata/next.config.js | 10 +++++ .../resume-streaming-metadata.test.ts | 32 ++++++++++++++ 18 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/@content/default.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/@content/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/@sidebar/default.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/(chat)/id/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/default.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/default.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/layout.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/layout.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot1/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot2/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot3/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/shift/layout.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/shift/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/app/test/page.tsx create mode 100644 test/e2e/app-dir/resume-streaming-metadata/next.config.js create mode 100644 test/e2e/app-dir/resume-streaming-metadata/resume-streaming-metadata.test.ts diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 843ecebc93fdf..6c75cce7b73b5 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -420,11 +420,10 @@ async function createComponentTreeInternal( ? process.env.__NEXT_EDGE_PROJECT_DIR : ctx.renderOpts.dir) || '' + console.log('StreamingMetadataOutlet', !!StreamingMetadataOutlet) + const metadataOutlet = ( - + ) const [notFoundElement, notFoundFilePath] = diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/@content/default.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/@content/default.tsx new file mode 100644 index 0000000000000..f018178a0228c --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/@content/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return
Content
+} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/@content/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/@content/page.tsx new file mode 100644 index 0000000000000..0ea85162e0467 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/@content/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' + +async function AsyncComponent() { + await new Promise((resolve) => setTimeout(resolve, 100)) + return
Async content {Math.random()}
+} + +export default function Page() { + return ( + Loading...}> + + + ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/@sidebar/default.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/@sidebar/default.tsx new file mode 100644 index 0000000000000..86b9e9a388129 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/@sidebar/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/(chat)/id/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/(chat)/id/page.tsx new file mode 100644 index 0000000000000..590b0ec1740fb --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/(chat)/id/page.tsx @@ -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 { + 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
No chat ID
+ } + + // Simulate some async work + await new Promise((resolve) => setTimeout(resolve, 50)) + const random = Math.random() + + return ( +
+

Chat {chatId}

+

Random: {random}

+
+ ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/default.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/default.tsx new file mode 100644 index 0000000000000..86b9e9a388129 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/@content/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/default.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/default.tsx new file mode 100644 index 0000000000000..86b9e9a388129 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/layout.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/layout.tsx new file mode 100644 index 0000000000000..34ca5c8e3416c --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/chat/[variants]/(dynamic-root)/(app)/layout.tsx @@ -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} + {content} + + ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/layout.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/layout.tsx new file mode 100644 index 0000000000000..9dcff4dfc4cf2 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/layout.tsx @@ -0,0 +1,22 @@ +import { ReactNode, Suspense } from 'react' +export default function Root({ + children, + sidebar, + content, +}: { + children: ReactNode + sidebar: ReactNode + content: ReactNode +}) { + return ( + + + + {sidebar} + {children} + {content} + + + + ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/page.tsx new file mode 100644 index 0000000000000..8ddf8ba784213 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/page.tsx @@ -0,0 +1,19 @@ +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ id: string | undefined }> +}) { + const { id } = await searchParams + const random = Math.random() + return ( +

+ hello world {random} {id} +

+ ) +} + +export const generateMetadata = async () => { + return { + title: 'Hello World', + } +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot1/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot1/page.tsx new file mode 100644 index 0000000000000..12a14f8ac282b --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot1/page.tsx @@ -0,0 +1,4 @@ +export default async function Slot1() { + await new Promise((resolve) => setTimeout(resolve, 10)) + return
Slot 1
+} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot2/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot2/page.tsx new file mode 100644 index 0000000000000..d2e5db1ef0bd9 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot2/page.tsx @@ -0,0 +1,4 @@ +export default async function Slot2() { + await new Promise((resolve) => setTimeout(resolve, 20)) + return
Slot 2
+} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot3/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot3/page.tsx new file mode 100644 index 0000000000000..92e2b23526e91 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/shift/@slot3/page.tsx @@ -0,0 +1,4 @@ +export default async function Slot3() { + await new Promise((resolve) => setTimeout(resolve, 30)) + return
Slot 3
+} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/shift/layout.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/shift/layout.tsx new file mode 100644 index 0000000000000..6b54574ec7efa --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/shift/layout.tsx @@ -0,0 +1,24 @@ +import { Suspense } from 'react' + +export default function Layout({ + children, + slot1, + slot2, + slot3, +}: { + children: React.ReactNode + slot1: React.ReactNode + slot2: React.ReactNode + slot3: React.ReactNode +}) { + // The order of these slots might differ between prerender and resume + return ( +
+ Loading slot1...
}>{slot1} + Loading slot2...}>{slot2} + {/* Children at index 2 - this is where metadata might interfere */} + {children} + Loading slot3...}>{slot3} + + ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/shift/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/shift/page.tsx new file mode 100644 index 0000000000000..2a6efc5753ea7 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/shift/page.tsx @@ -0,0 +1,29 @@ +import { headers } from 'next/headers' +import { connection } from 'next/server' + +export async function generateMetadata() { + // This async metadata generation might cause timing differences + const headersList = await headers() + const id = headersList.get('x-id') || 'default' + + // Simulate DB call + await new Promise((resolve) => setTimeout(resolve, 50)) + + return { + title: `Page ${id}`, + description: `Description for ${id}`, + } +} + +export default async function Page() { + await connection() + // Dynamic content that changes between renders + const random = Math.random() + + return ( +
+

Main Page Content

+

Random: {random}

+
+ ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/app/test/page.tsx b/test/e2e/app-dir/resume-streaming-metadata/app/test/page.tsx new file mode 100644 index 0000000000000..3d23a07ad1aea --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/app/test/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from 'react' +import { headers } from 'next/headers' + +// Force multiple components before metadata +function Component1() { + return
Component 1
+} + +function Component2() { + return
Component 2
+} + +async function AsyncComponent() { + // This component loads async data that might affect position + await new Promise((resolve) => setTimeout(resolve, 50)) + return
Async Component
+} + +export async function generateMetadata() { + // Async metadata that takes time + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Try to access headers which might affect timing + const headersList = await headers() + const testHeader = headersList.get('x-test') || 'default' + + return { + title: `Test Page - ${testHeader}`, + description: 'Testing metadata boundary position', + } +} + +export default function TestPage() { + return ( + <> + + + Loading...}> + + +
Main content at index 3
+ + ) +} diff --git a/test/e2e/app-dir/resume-streaming-metadata/next.config.js b/test/e2e/app-dir/resume-streaming-metadata/next.config.js new file mode 100644 index 0000000000000..30a826fdacc56 --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + cacheComponents: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/resume-streaming-metadata/resume-streaming-metadata.test.ts b/test/e2e/app-dir/resume-streaming-metadata/resume-streaming-metadata.test.ts new file mode 100644 index 0000000000000..0d49b70a3a90a --- /dev/null +++ b/test/e2e/app-dir/resume-streaming-metadata/resume-streaming-metadata.test.ts @@ -0,0 +1,32 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('resume-streaming-metadata', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + // Recommended for tests that check HTML. Cheerio is a HTML parser that has a jQuery like API. + it('should work using cheerio', async () => { + const $ = await next.render$('/') + expect($('p').text()).toBe('hello world') + }) + + // Recommended for tests that need a full browser + it('should work using browser', async () => { + const browser = await next.browser('/') + expect(await browser.elementByCss('p').text()).toBe('hello world') + }) + + // In case you need the full HTML. Can also use $.html() with cheerio. + it('should work with html', async () => { + const html = await next.render('/') + expect(html).toContain('hello world') + }) + + // In case you need to test the response object + it('should work with fetch', async () => { + const res = await next.fetch('/') + const html = await res.text() + expect(html).toContain('hello world') + }) +}) From 040cd09a2dc098682ed92e1412e682ee9422fddf Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 11 Aug 2025 22:12:19 -0400 Subject: [PATCH 04/11] rule out StreamingMetadataOutlet variable and test again, take 2 --- packages/next/src/server/app-render/create-component-tree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 6c75cce7b73b5..f5e281f4511f4 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -823,7 +823,7 @@ async function createComponentTreeInternal( {wrappedPageElement} {layerAssets} - + {metadataOutlet} , From 0c0d3a28513d3656699d9a12f57d494d3882a99a Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 11 Aug 2025 23:03:16 -0400 Subject: [PATCH 05/11] rule out StreamingMetadataOutlet variable and test again, take 3 --- .../cjs/react-dom-server.node.production.js | 2 +- packages/next/src/lib/metadata/metadata.tsx | 18 +++++++++++------- .../app-render/create-component-tree.tsx | 11 +++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js b/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js index 1b3dcfe028e8b..a411b9e45e5c1 100644 --- a/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js +++ b/packages/next/src/compiled/react-dom-experimental/cjs/react-dom-server.node.production.js @@ -10,7 +10,7 @@ "use strict"; -console.log('file override react-dom-server.node.production.js') +console.log('file override react-dom-server.node.production.js 111') var util = require("util"), crypto = require("crypto"), diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 99e2b4fff65a1..5ad42aef64232 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -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' @@ -243,13 +243,17 @@ export function createMetadataComponents({ return undefined } - function StreamingMetadataOutletImpl() { - return - } + // function StreamingMetadataOutletImpl() { + // return + // } - 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, diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index f5e281f4511f4..c31eed0ae6f46 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -420,10 +420,13 @@ async function createComponentTreeInternal( ? process.env.__NEXT_EDGE_PROJECT_DIR : ctx.renderOpts.dir) || '' - console.log('StreamingMetadataOutlet', !!StreamingMetadataOutlet) - + // StreamingMetadataOutlet is now always null from createMetadataComponents + // to ensure consistency between prerender and resume for PPR routes const metadataOutlet = ( - + ) const [notFoundElement, notFoundFilePath] = @@ -823,7 +826,7 @@ async function createComponentTreeInternal( {wrappedPageElement} {layerAssets} - + {metadataOutlet} , From 2b96873b68b1f146d5cfa8703291e852c3314a17 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 11:21:14 -0400 Subject: [PATCH 06/11] hack: hardcode serveStreamingMetadata to true --- packages/next/src/build/templates/app-page.ts | 12 ++++++++---- packages/next/src/lib/metadata/metadata.tsx | 3 +++ packages/next/src/server/app-render/app-render.tsx | 12 +++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 9d4a74b86f30d..53548e2533c10 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -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 + 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. diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 5ad42aef64232..639e53018a7b6 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -80,6 +80,9 @@ export function createMetadataComponents({ getViewportReady: () => Promise StreamingMetadataOutlet: React.ComponentType | null } { + // TEST: Hardcode serveStreamingMetadata to true to rule out edge case + serveStreamingMetadata = true + const searchParams = createServerSearchParamsForMetadata( parsedQuery, workStore diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index f72974bfa3dac..0da3d10fc352f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -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 = [] @@ -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 { @@ -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, From 2875e120da7b1f83c1f91d35ccb0c1cc2102b2f5 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 12:49:19 -0400 Subject: [PATCH 07/11] remove outlet --- packages/next/src/server/app-render/create-component-tree.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index c31eed0ae6f46..376cab201c38d 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -825,10 +825,12 @@ async function createComponentTreeInternal( {wrappedPageElement} {layerAssets} + {/* TEST: Commenting out metadata outlets to verify error source {metadataOutlet} + */} , parallelRouteCacheNodeSeedData, loadingData, From 63c36bf1204d78762afb3370e92809084728dcec Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 13:48:07 -0400 Subject: [PATCH 08/11] rule out mismatch from head --- packages/next/src/client/components/app-router.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 6594ef4afc5ce..80103f13fcae8 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -477,12 +477,13 @@ function Router({ const [headCacheNode, headKey] = matchingHead head = } else { - head = null + // Always render a consistent placeholder to maintain tree structure during PPR + head = <>{null} } let content = ( - {head} + {/* {head} <- rule out mismatch from here */} {/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout. When users wrap their layout in , this creates the component stack pattern "Suspense -> RootLayoutBoundary" which dynamic-rendering.ts uses to allow dynamic rendering. */} From a1ddf520f69578bb537798af70dceda707f8992e Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 14:15:23 -0400 Subject: [PATCH 09/11] conditional head --- .../next/src/client/components/app-router.tsx | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 80103f13fcae8..e001428716e71 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -466,23 +466,26 @@ function Router({ } }, [tree, focusAndScrollRef, nextUrl]) - let head - if (matchingHead !== null) { - // The head is wrapped in an extra component so we can use - // `useDeferredValue` to swap between the prefetched and final versions of - // the head. (This is what LayoutRouter does for segment data, too.) - // - // The `key` is used to remount the component whenever the head moves to - // a different segment. - const [headCacheNode, headKey] = matchingHead - head = - } else { - // Always render a consistent placeholder to maintain tree structure during PPR - head = <>{null} - } + // let head + // if (matchingHead !== null) { + // // The head is wrapped in an extra component so we can use + // // `useDeferredValue` to swap between the prefetched and final versions of + // // the head. (This is what LayoutRouter does for segment data, too.) + // // + // // The `key` is used to remount the component whenever the head moves to + // // a different segment. + // const [headCacheNode, headKey] = matchingHead + // head = + // } else { + // // Always render a consistent placeholder to maintain tree structure during PPR + // head = <>{null} + // } + + console.log('matchingHead', matchingHead) let content = ( + {/* {head} <- rule out mismatch from here */} {/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout. When users wrap their layout in , this creates the component stack pattern @@ -629,3 +632,25 @@ function RuntimeStyles() { /> )) } + +const ConditionalHead = ({ + matchingHead, +}: { + matchingHead: [CacheNode, string] | null +}) => { + let head + if (matchingHead !== null) { + // The head is wrapped in an extra component so we can use + // `useDeferredValue` to swap between the prefetched and final versions of + // the head. (This is what LayoutRouter does for segment data, too.) + // + // The `key` is used to remount the component whenever the head moves to + // a different segment. + const [headCacheNode, headKey] = matchingHead + head = + } else { + // Always render a consistent placeholder to maintain tree structure during PPR + head = <>{null} + } + return head +} From 9a971211433788ccfac21793ee4e9cc42010b3ac Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 14:39:37 -0400 Subject: [PATCH 10/11] fix head mismatch --- .../next/src/client/components/app-router.tsx | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index e001428716e71..2592c961c60dc 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -482,10 +482,11 @@ function Router({ // } console.log('matchingHead', matchingHead) + const [headCacheNode, headKey] = matchingHead ?? [null, null] let content = ( - + {/* {head} <- rule out mismatch from here */} {/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout. When users wrap their layout in , this creates the component stack pattern @@ -632,25 +633,3 @@ function RuntimeStyles() { /> )) } - -const ConditionalHead = ({ - matchingHead, -}: { - matchingHead: [CacheNode, string] | null -}) => { - let head - if (matchingHead !== null) { - // The head is wrapped in an extra component so we can use - // `useDeferredValue` to swap between the prefetched and final versions of - // the head. (This is what LayoutRouter does for segment data, too.) - // - // The `key` is used to remount the component whenever the head moves to - // a different segment. - const [headCacheNode, headKey] = matchingHead - head = - } else { - // Always render a consistent placeholder to maintain tree structure during PPR - head = <>{null} - } - return head -} From e3a2efde339bd660778392d7eddc6e3d6a1b54af Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Tue, 12 Aug 2025 15:28:30 -0400 Subject: [PATCH 11/11] remove key from head --- packages/next/src/client/components/app-router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 2592c961c60dc..26ce0898cc9ca 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -486,7 +486,7 @@ function Router({ let content = ( - + {/* {head} <- rule out mismatch from here */} {/* RootLayoutBoundary enables detection of Suspense boundaries around the root layout. When users wrap their layout in , this creates the component stack pattern