From 5e3686ac55c0020ab843e8a2648f180ad9f33ca6 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:10:23 -0800 Subject: [PATCH 1/4] [streaming metadata]: ensure metadata boundary is only rendered once on client nav --- packages/next/src/server/app-render/app-render.tsx | 2 +- .../walk-tree-with-flight-router-state.tsx | 5 +---- .../app/parallel-routes/@bar/test-page/page.tsx | 3 +++ .../app/parallel-routes/@foo/test-page/page.tsx | 3 +++ .../metadata-streaming/app/parallel-routes/page.tsx | 11 ++++++++++- .../app/parallel-routes/test-page/page.tsx | 13 +++++++++++++ .../metadata-streaming/metadata-streaming.test.ts | 11 +++++++++++ 7 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 test/e2e/app-dir/metadata-streaming/app/parallel-routes/@bar/test-page/page.tsx create mode 100644 test/e2e/app-dir/metadata-streaming/app/parallel-routes/@foo/test-page/page.tsx create mode 100644 test/e2e/app-dir/metadata-streaming/app/parallel-routes/test-page/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 82a599d5324a1..80d0fb22ceb3f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -522,6 +522,7 @@ async function generateDynamicRSCPayload( {/* Adding requestId as react key to make metadata remount for each render */} + {StreamingMetadata ? : null} ), @@ -532,7 +533,6 @@ async function generateDynamicRSCPayload( getViewportReady, getMetadataReady, preloadCallbacks, - StreamingMetadata, StreamingMetadataOutlet, }) ).map((path) => path.slice(1)) // remove the '' (root) segment 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 a8a74cac7cb0c..776b6ed6f714b 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, - StreamingMetadata, StreamingMetadataOutlet, }: { loaderTreeToFilter: LoaderTree @@ -56,7 +55,6 @@ export async function walkTreeWithFlightRouterState({ getViewportReady: () => Promise ctx: AppRenderContext preloadCallbacks: PreloadCallbacks - StreamingMetadata: React.ComponentType<{}> | null StreamingMetadataOutlet: React.ComponentType<{}> }): Promise { 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) {