{
const {
@@ -206,7 +204,7 @@ export async function walkTreeWithFlightRouterState({
getMetadataReady,
preloadCallbacks,
authInterrupts: experimental.authInterrupts,
- StreamingMetadata,
+ StreamingMetadata: null,
StreamingMetadataOutlet,
}
)
@@ -267,7 +265,6 @@ export async function walkTreeWithFlightRouterState({
getViewportReady,
getMetadataReady,
preloadCallbacks,
- StreamingMetadata,
StreamingMetadataOutlet,
})
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@bar/test-page/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@bar/test-page/page.tsx
new file mode 100644
index 0000000000000..bff087a3a98f9
--- /dev/null
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@bar/test-page/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 'test-page @bar'
+}
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/test-page/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/test-page/page.tsx
new file mode 100644
index 0000000000000..8eff01dbe6dec
--- /dev/null
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/test-page/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 'test-page @foo'
+}
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
index 605d53d215519..272e995f18ab2 100644
--- a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
@@ -1,5 +1,14 @@
+import Link from 'next/link'
+
export default function Page() {
- return Hello from Nested
+ return (
+
+ Hello from Nested{' '}
+
+ To /parallel-routes/test-page
+
+
+ )
}
export const metadata = {
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/test-page/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/test-page/page.tsx
new file mode 100644
index 0000000000000..640e2e61603f7
--- /dev/null
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/test-page/page.tsx
@@ -0,0 +1,13 @@
+import { connection } from 'next/server'
+
+export default function TestPage() {
+ return 'test page'
+}
+
+export async function generateMetadata() {
+ await connection()
+ await new Promise((resolve) => setTimeout(resolve, 3000))
+ return {
+ title: `Dynamic api ${Math.random()}`,
+ }
+}
diff --git a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
index 2aabf91805369..a50b9e26b6692 100644
--- a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
+++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
@@ -96,6 +96,17 @@ describe('app-dir - metadata-streaming', () => {
const $ = await next.render$('/parallel-routes')
expect($('title').length).toBe(1)
+
+ // validate behavior remains the same on client navigations
+ await browser.elementByCss('[href="/parallel-routes/test-page"]').click()
+
+ await retry(async () => {
+ expect(await browser.elementByCss('title').text()).toContain(
+ 'Dynamic api'
+ )
+ })
+
+ expect((await browser.elementsByCss('title')).length).toBe(1)
})
describe('dynamic api', () => {
From 72fa5c8babbe04dcc6aa565c0a076dcee323fe3c Mon Sep 17 00:00:00 2001
From: Zack Tanner <1939140+ztanner@users.noreply.github.com>
Date: Fri, 28 Feb 2025 17:41:07 -0800
Subject: [PATCH 2/4] add failing test
---
.../next/src/server/app-render/app-render.tsx | 2 +-
.../server/app-render/create-component-tree.tsx | 2 +-
.../walk-tree-with-flight-router-state.tsx | 5 +----
.../app/parallel-routes/@foo/no-bar/page.tsx | 3 +++
.../app/parallel-routes/no-bar/page.tsx | 13 +++++++++++++
.../app/parallel-routes/page.tsx | 1 +
.../metadata-streaming/metadata-streaming.test.ts | 15 ++++++++++++++-
7 files changed, 34 insertions(+), 7 deletions(-)
create mode 100644 test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/no-bar/page.tsx
create mode 100644 test/e2e/app-dir/metadata-streaming/app/parallel-routes/no-bar/page.tsx
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 80d0fb22ceb3f..68525720097a8 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -523,6 +523,7 @@ async function generateDynamicRSCPayload(
{/* Adding requestId as react key to make metadata remount for each render */}
{StreamingMetadata ? : null}
+ {StreamingMetadataOutlet ? : null}
),
@@ -533,7 +534,6 @@ async function generateDynamicRSCPayload(
getViewportReady,
getMetadataReady,
preloadCallbacks,
- StreamingMetadataOutlet,
})
).map((path) => path.slice(1)) // remove the '' (root) segment
}
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 ccb0dcbfa6831..b01700194743c 100644
--- a/packages/next/src/server/app-render/create-component-tree.tsx
+++ b/packages/next/src/server/app-render/create-component-tree.tsx
@@ -41,7 +41,7 @@ export function createComponentTree(props: {
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}> | null
- StreamingMetadataOutlet: React.ComponentType
+ StreamingMetadataOutlet: React.ComponentType | null
}): Promise {
return getTracer().trace(
NextNodeServerSpan.createComponentTree,
diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
index 776b6ed6f714b..671ebaf50a6ea 100644
--- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
+++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
@@ -40,7 +40,6 @@ export async function walkTreeWithFlightRouterState({
getMetadataReady,
ctx,
preloadCallbacks,
- StreamingMetadataOutlet,
}: {
loaderTreeToFilter: LoaderTree
parentParams: { [key: string]: string | string[] }
@@ -55,7 +54,6 @@ export async function walkTreeWithFlightRouterState({
getViewportReady: () => Promise
ctx: AppRenderContext
preloadCallbacks: PreloadCallbacks
- StreamingMetadataOutlet: React.ComponentType<{}>
}): Promise {
const {
renderOpts: { nextFontManifest, experimental },
@@ -205,7 +203,7 @@ export async function walkTreeWithFlightRouterState({
preloadCallbacks,
authInterrupts: experimental.authInterrupts,
StreamingMetadata: null,
- StreamingMetadataOutlet,
+ StreamingMetadataOutlet: null,
}
)
@@ -265,7 +263,6 @@ export async function walkTreeWithFlightRouterState({
getViewportReady,
getMetadataReady,
preloadCallbacks,
- StreamingMetadataOutlet,
})
for (const subPath of subPaths) {
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/no-bar/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/no-bar/page.tsx
new file mode 100644
index 0000000000000..44ab40e2ddfce
--- /dev/null
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/no-bar/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 'no-bar @foo'
+}
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/no-bar/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/no-bar/page.tsx
new file mode 100644
index 0000000000000..640e2e61603f7
--- /dev/null
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/no-bar/page.tsx
@@ -0,0 +1,13 @@
+import { connection } from 'next/server'
+
+export default function TestPage() {
+ return 'test page'
+}
+
+export async function generateMetadata() {
+ await connection()
+ await new Promise((resolve) => setTimeout(resolve, 3000))
+ return {
+ title: `Dynamic api ${Math.random()}`,
+ }
+}
diff --git a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
index 272e995f18ab2..5739a4c02e37b 100644
--- a/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
+++ b/test/e2e/app-dir/metadata-streaming/app/parallel-routes/page.tsx
@@ -7,6 +7,7 @@ export default function Page() {
To /parallel-routes/test-page
+ To /parallel-routes/no-bar
)
}
diff --git a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
index a50b9e26b6692..eae22f99affe4 100644
--- a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
+++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
@@ -88,7 +88,7 @@ describe('app-dir - metadata-streaming', () => {
expect((await browser.elementsByCss('body meta')).length).toBe(9)
})
- it('should only insert metadata once for parallel routes', async () => {
+ it('should only insert metadata once for parallel routes when slots match', async () => {
const browser = await next.browser('/parallel-routes')
expect((await browser.elementsByCss('head title')).length).toBe(1)
@@ -109,6 +109,19 @@ describe('app-dir - metadata-streaming', () => {
expect((await browser.elementsByCss('title')).length).toBe(1)
})
+ it('should only insert metadata once for parallel routes when there is a missing slot', async () => {
+ const browser = await next.browser('/parallel-routes')
+ await browser.elementByCss('[href="/parallel-routes/no-bar"]').click()
+
+ await retry(async () => {
+ expect(await browser.elementByCss('title').text()).toContain(
+ 'Dynamic api'
+ )
+ })
+
+ expect((await browser.elementsByCss('title')).length).toBe(1)
+ })
+
describe('dynamic api', () => {
it('should render metadata to body', async () => {
const $ = await next.render$('/dynamic-api')
From 7364e6e008dfb99b454267ff6a7b4a080a9f3d08 Mon Sep 17 00:00:00 2001
From: Zack Tanner <1939140+ztanner@users.noreply.github.com>
Date: Sat, 1 Mar 2025 08:22:08 -0800
Subject: [PATCH 3/4] ensure children head node is prioritized over parallel
routes
---
.../reducers/find-head-in-cache.ts | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts
index 58d4b3451f5af..db6f9eb66502d 100644
--- a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts
+++ b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts
@@ -19,7 +19,30 @@ function findHeadInCacheImpl(
// Returns the entire Cache Node of the segment whose head we will render.
return [cache, keyPrefix]
}
+
+ // First try the 'children' parallel route if it exists
+ // when starting from the "root", this corresponds with the main page component
+ if (parallelRoutes.children) {
+ const [segment, childParallelRoutes] = parallelRoutes.children
+ const childSegmentMap = cache.parallelRoutes.get('children')
+ if (childSegmentMap) {
+ const cacheKey = createRouterCacheKey(segment)
+ const cacheNode = childSegmentMap.get(cacheKey)
+ if (cacheNode) {
+ const item = findHeadInCacheImpl(
+ cacheNode,
+ childParallelRoutes,
+ keyPrefix + '/' + cacheKey
+ )
+ if (item) return item
+ }
+ }
+ }
+
+ // if we didn't find metadata in the page slot, check the other parallel routes
for (const key in parallelRoutes) {
+ if (key === 'children') continue // already checked above
+
const [segment, childParallelRoutes] = parallelRoutes[key]
const childSegmentMap = cache.parallelRoutes.get(key)
if (!childSegmentMap) {
From aae30db4ceef012a608bed6fe9a21200ce47a1fb Mon Sep 17 00:00:00 2001
From: Zack Tanner <1939140+ztanner@users.noreply.github.com>
Date: Sat, 1 Mar 2025 11:46:50 -0800
Subject: [PATCH 4/4] revert outlet change
---
packages/next/src/server/app-render/app-render.tsx | 2 +-
.../next/src/server/app-render/create-component-tree.tsx | 2 +-
.../server/app-render/walk-tree-with-flight-router-state.tsx | 5 ++++-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 68525720097a8..80d0fb22ceb3f 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -523,7 +523,6 @@ async function generateDynamicRSCPayload(
{/* Adding requestId as react key to make metadata remount for each render */}
{StreamingMetadata ? : null}
- {StreamingMetadataOutlet ? : null}
),
@@ -534,6 +533,7 @@ async function generateDynamicRSCPayload(
getViewportReady,
getMetadataReady,
preloadCallbacks,
+ StreamingMetadataOutlet,
})
).map((path) => path.slice(1)) // remove the '' (root) segment
}
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 b01700194743c..ccb0dcbfa6831 100644
--- a/packages/next/src/server/app-render/create-component-tree.tsx
+++ b/packages/next/src/server/app-render/create-component-tree.tsx
@@ -41,7 +41,7 @@ export function createComponentTree(props: {
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}> | null
- StreamingMetadataOutlet: React.ComponentType | null
+ StreamingMetadataOutlet: React.ComponentType
}): Promise {
return getTracer().trace(
NextNodeServerSpan.createComponentTree,
diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
index 671ebaf50a6ea..21300e96bf319 100644
--- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
+++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx
@@ -40,6 +40,7 @@ export async function walkTreeWithFlightRouterState({
getMetadataReady,
ctx,
preloadCallbacks,
+ StreamingMetadataOutlet,
}: {
loaderTreeToFilter: LoaderTree
parentParams: { [key: string]: string | string[] }
@@ -54,6 +55,7 @@ export async function walkTreeWithFlightRouterState({
getViewportReady: () => Promise
ctx: AppRenderContext
preloadCallbacks: PreloadCallbacks
+ StreamingMetadataOutlet: React.ComponentType
}): Promise {
const {
renderOpts: { nextFontManifest, experimental },
@@ -203,7 +205,7 @@ export async function walkTreeWithFlightRouterState({
preloadCallbacks,
authInterrupts: experimental.authInterrupts,
StreamingMetadata: null,
- StreamingMetadataOutlet: null,
+ StreamingMetadataOutlet,
}
)
@@ -263,6 +265,7 @@ export async function walkTreeWithFlightRouterState({
getViewportReady,
getMetadataReady,
preloadCallbacks,
+ StreamingMetadataOutlet,
})
for (const subPath of subPaths) {