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
56 changes: 12 additions & 44 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Suspense, cache, cloneElement } from 'react'
import React, { cache, cloneElement } from 'react'
import type { ParsedUrlQuery } from 'querystring'
import type { GetDynamicParamFromSegment } from '../../server/app-render/app-render'
import type { LoaderTree } from '../../server/lib/app-dir-module'
Expand Down Expand Up @@ -57,7 +57,6 @@ export function createMetadataComponents({
getDynamicParamFromSegment,
errorType,
workStore,
serveStreamingMetadata,
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 parameter is declared in the type signature but not destructured in the function parameters, causing an incomplete refactoring. This should be removed from the type signature since it's no longer used.

View Details
📝 Patch Details
diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx
index 86fbf614fb..1b2bef98a5 100644
--- a/packages/next/src/lib/metadata/metadata.tsx
+++ b/packages/next/src/lib/metadata/metadata.tsx
@@ -65,7 +65,6 @@ export function createMetadataComponents({
   getDynamicParamFromSegment: GetDynamicParamFromSegment
   errorType?: MetadataErrorType | 'redirect'
   workStore: WorkStore
-  serveStreamingMetadata: boolean
 }): {
   Viewport: React.ComponentType
   Metadata: React.ComponentType
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 3c9423424e..3e71fc8f44 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -495,7 +495,7 @@ async function generateDynamicRSCPayload(
       metadataContext: createMetadataContext(ctx.renderOpts),
       getDynamicParamFromSegment,
       workStore,
-      serveStreamingMetadata,
+
     })
 
     flightData = (
@@ -1254,7 +1254,6 @@ async function getRSCPayload(
     metadataContext: createMetadataContext(ctx.renderOpts),
     getDynamicParamFromSegment,
     workStore,
-    serveStreamingMetadata,
   })
 
   const preloadCallbacks: PreloadCallbacks = []
@@ -1375,7 +1374,7 @@ async function getErrorRSCPayload(
     errorType,
     getDynamicParamFromSegment,
     workStore,
-    serveStreamingMetadata: serveStreamingMetadata,
+
   })
 
   const initialHead = createElement(

Analysis

Incomplete refactoring: unused serveStreamingMetadata parameter in createMetadataComponents

What fails: The createMetadataComponents() function has serveStreamingMetadata: boolean in its type signature but never destructures or uses it in the function body. This creates an inconsistency where callers must provide the parameter even though it's ignored at runtime.

How to reproduce: Look at the function definition in packages/next/src/lib/metadata/metadata.tsx (lines 53-68):

  • Lines 53-59 destructure parameters: tree, pathname, parsedQuery, metadataContext, getDynamicParamFromSegment, errorType, workStore
  • Line 68 declares in type signature: serveStreamingMetadata: boolean (NOT destructured)
  • Function body (lines 72+) never references serveStreamingMetadata
  • Callers in app-render.tsx (lines 498, 1257, 1378) provide serveStreamingMetadata but it's silently ignored

Result: Parameter is silently ignored at runtime; incomplete refactoring where the parameter should have been removed from the type signature.

Expected: The type signature should only include parameters that are actually used. The serveStreamingMetadata parameter has been removed from the destructuring but was left in the type signature during a refactoring.

Fix applied:

  • Removed serveStreamingMetadata: boolean from the type signature in packages/next/src/lib/metadata/metadata.tsx line 68
  • Removed serveStreamingMetadata parameter from all three call sites in packages/next/src/server/app-render/app-render.tsx (lines 498, 1257, 1378)

}: {
tree: LoaderTree
pathname: string
Expand All @@ -81,8 +80,12 @@ export function createMetadataComponents({
workStore
)

function Viewport() {
const pendingViewportTags = getResolvedViewport(
// Metadata must be part of the initial SSR HTML response for React's server-side
// hoisting to work. When metadata is sent via RSC Flight data, it bypasses React's
// hoisting logic and stays in <body>. Therefore, we always await metadata to ensure
// it's included in the initial HTML where React can properly hoist it to <head>.
async function Viewport() {
const viewportTags = await getResolvedViewport(
tree,
searchParams,
getDynamicParamFromSegment,
Expand All @@ -107,17 +110,12 @@ export function createMetadataComponents({
return null
})

return (
<ViewportBoundary>
{/* @ts-expect-error -- Promise<ReactNode> not considered a valid child even though it is */}
{pendingViewportTags}
</ViewportBoundary>
)
return <ViewportBoundary>{viewportTags}</ViewportBoundary>
}
Viewport.displayName = 'Next.Viewport'

function Metadata() {
const pendingMetadataTags = getResolvedMetadata(
async function Metadata() {
const metadataTags = await getResolvedMetadata(
tree,
pathnameForMetadata,
searchParams,
Expand Down Expand Up @@ -146,27 +144,7 @@ export function createMetadataComponents({
return null
})

// TODO: We shouldn't change what we render based on whether we are streaming or not.
// If we aren't streaming we should just block the response until we have resolved the
// metadata.
if (!serveStreamingMetadata) {
return (
<MetadataBoundary>
{/* @ts-expect-error -- Promise<ReactNode> not considered a valid child even though it is */}
{pendingMetadataTags}
</MetadataBoundary>
)
}
return (
<div hidden>
<MetadataBoundary>
<Suspense name="Next.Metadata">
{/* @ts-expect-error -- Promise<ReactNode> not considered a valid child even though it is */}
{pendingMetadataTags}
</Suspense>
</MetadataBoundary>
</div>
)
return <MetadataBoundary>{metadataTags}</MetadataBoundary>
}
Metadata.displayName = 'Next.Metadata'

Expand All @@ -190,17 +168,7 @@ export function createMetadataComponents({
),
]).then(() => null)

// TODO: We shouldn't change what we render based on whether we are streaming or not.
// If we aren't streaming we should just block the response until we have resolved the
// metadata.
if (!serveStreamingMetadata) {
return <OutletBoundary>{pendingOutlet}</OutletBoundary>
}
return (
<OutletBoundary>
<Suspense name="Next.MetadataOutlet">{pendingOutlet}</Suspense>
</OutletBoundary>
)
return <OutletBoundary>{pendingOutlet}</OutletBoundary>
}
MetadataOutlet.displayName = 'Next.MetadataOutlet'

Expand Down
18 changes: 10 additions & 8 deletions test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ describe('app-dir - metadata-icons', () => {

it('should not contain icon insertion script when metadata is rendered in head', async () => {
const suspendedHtml = await next.render('/custom-icon')
expect(suspendedHtml).toContain(iconInsertionScript)
// With the metadata fix, icons are in head from SSR, so no insertion script needed
expect(suspendedHtml).not.toContain(iconInsertionScript)
})

it('should not contain icon replacement mark in html or after hydration', async () => {
Expand Down Expand Up @@ -113,20 +114,21 @@ describe('app-dir - metadata-icons', () => {
})
})

it('should re-insert the icons into head when icons are inserted in body during initial chunk', async () => {
it('should render icons in head even for delayed icon metadata', async () => {
const $ = await next.render$('/custom-icon/delay-icons')
expect($('meta[name="«nxt-icon»"]').length).toBe(0)

// body will contain the icons and the script to insert them into head
const body = $('body')
const icons = body.find('link[rel="icon"]')
expect(icons.length).toBe(2)
// With the metadata fix, icons are now in head from SSR, not body
const head = $('head')
const icons = head.find('link[rel="icon"]')
expect(icons.length).toBeGreaterThanOrEqual(2)
expect(Array.from(icons.map((_, el) => $(el).attr('href')))).toContain(
'/heart.png'
)

const bodyHtml = body.html()
expect(bodyHtml).toContain(iconInsertionScript)
// No insertion script needed since icons are already in head
const bodyHtml = $('body').html()
expect(bodyHtml).not.toContain(iconInsertionScript)

// icons should be inserted into head after hydration
const browser = await next.browser('/custom-icon/delay-icons')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,10 @@ const isPPREnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'
;(isPPREnabled ? describe.skip : describe)(
'app-dir - metadata-static-generation',
() => {
const { next, isNextDev, isNextStart } = nextTestSetup({
const { next, isNextStart } = nextTestSetup({
files: __dirname,
})

// In dev, it suspenses as dynamic rendering so it's inserted into body;
// In build, it's resolved as static rendering so it's inserted into head.
const rootSelector = isNextDev ? 'body' : 'head'

if (isNextStart) {
// Precondition for the following tests in build mode.
// This test is only useful for non-PPR mode as in PPR mode those routes
Expand All @@ -33,22 +29,22 @@ const isPPREnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'

it('should contain async generated metadata in head for simple static page', async () => {
const $ = await next.render$('/')
expect($(`${rootSelector} title`).text()).toBe('index page')
expect($(`head title`).text()).toBe('index page')
expect(
$(`${rootSelector} meta[name="description"]`).attr('content')
$(`head meta[name="description"]`).attr('content')
).toBe('index page description')
})

it('should contain async generated metadata in head static page with suspenseful content', async () => {
const $ = await next.render$('/suspenseful/static')
expect($(`${rootSelector} title`).text()).toBe(
expect($(`head title`).text()).toBe(
'suspenseful page - static'
)
})

it('should contain async generated metadata in body for dynamic page', async () => {
it('should contain async generated metadata in head for dynamic page', async () => {
const $ = await next.render$('/suspenseful/dynamic')
expect($('body title').text()).toBe('suspenseful page - dynamic')
expect($('head title').text()).toBe('suspenseful page - dynamic')
})
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ describe('app-dir - metadata-streaming', () => {

const $ = await next.render$('/parallel-routes-default')
expect($('title').length).toBe(1)
expect($('body title').text()).toBe('parallel-routes-default layout title')
// Metadata is now correctly in head, not body
expect($('head title').text()).toBe('parallel-routes-default layout title')
expect($('body title').length).toBe(0)
})

it('should change metadata when navigating between two pages under a slot when children is not rendered', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@ const isPPREnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'
}

if (isNextDev) {
// In development it's still dynamic rendering that metadata will be inserted into body
// With the fix, metadata is now in head even in development
describe('static pages (development)', () => {
it('should contain async generated metadata in body for simple static page', async () => {
it('should contain async generated metadata in head for simple static page', async () => {
const $ = await next.render$('/')
expect($('body title').text()).toBe('index page')
expect($('head title').text()).toBe('index page')
})

it('should contain async generated metadata in body for slow static page', async () => {
it('should contain async generated metadata in head for slow static page', async () => {
const $ = await next.render$('/slow/static')
expect($('body title').text()).toBe('slow page - static')
expect($('head title').text()).toBe('slow page - static')
})

it('should contain async generated metadata in body static page with suspenseful content', async () => {
it('should contain async generated metadata in head static page with suspenseful content', async () => {
const $ = await next.render$('/suspenseful/static')
expect($('body title').text()).toBe('suspenseful page - static')
expect($('head title').text()).toBe('suspenseful page - static')
})
})
} else {
Expand All @@ -68,14 +68,14 @@ const isPPREnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'
}

describe('dynamic pages', () => {
it('should contain async generated metadata in body for simple dynamics page', async () => {
it('should contain async generated metadata in head for simple dynamics page', async () => {
const $ = await next.render$('/suspenseful/dynamic')
expect($('body title').text()).toBe('suspenseful page - dynamic')
expect($('head title').text()).toBe('suspenseful page - dynamic')
})

it('should contain async generated metadata in body for suspenseful dynamic page', async () => {
it('should contain async generated metadata in head for suspenseful dynamic page', async () => {
const $ = await next.render$('/slow/dynamic')
expect($('body title').text()).toBe('slow page - dynamic')
expect($('head title').text()).toBe('slow page - dynamic')
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ describe('app-dir - metadata-streaming-customized-rule', () => {
expect(await $('body title').length).toBe(0)
})

it('should send streaming response for headless browser bots', async () => {
it('should send blocking response for all user agents', async () => {
// With the metadata fix, all user agents now get metadata in head
// regardless of htmlLimitedBots configuration
const $ = await next.render$(
'/',
undefined, // no query
Expand All @@ -36,8 +38,8 @@ describe('app-dir - metadata-streaming-customized-rule', () => {
},
}
)
expect(await $('head title').length).toBe(0)
expect(await $('body title').length).toBe(1)
expect(await $('head title').text()).toBe('index page')
expect(await $('body title').length).toBe(0)
})

if (isNextDev) {
Expand Down
40 changes: 20 additions & 20 deletions test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ describe('app-dir - metadata-streaming', () => {
files: __dirname,
})

it('should delay the metadata render to body', async () => {
it('should render metadata in head for SEO', async () => {
const $ = await next.render$('/')
expect($('head title').length).toBe(0)
expect($('body title').length).toBe(1)
expect($('head title').length).toBe(1)
expect($('head title').text()).toBe('index page')
})

it('should still load viewport meta tags even if metadata is delayed', async () => {
Expand Down Expand Up @@ -73,34 +73,34 @@ describe('app-dir - metadata-streaming', () => {
})
})

it('should only insert metadata once into head or body', async () => {
it('should only insert metadata once in head', async () => {
const browser = await next.browser('/slow')

// each metadata should be inserted only once
// each metadata should be inserted only once in head
expect(await browser.hasElementByCssSelector('head title')).toBe(true)
expect((await browser.elementsByCss('head title')).length).toBe(1)

expect(await browser.hasElementByCssSelector('head title')).toBe(false)
// all metadata should be rendered in head (charset, viewport, and 9 others = 11 total)
expect((await browser.elementsByCss('head meta')).length).toBeGreaterThanOrEqual(11)

// only charset and viewport are rendered in head
expect((await browser.elementsByCss('head meta')).length).toBe(2)
expect((await browser.elementsByCss('body title')).length).toBe(1)

// all metadata should be rendered in body
expect((await browser.elementsByCss('body meta')).length).toBe(9)
// no metadata in body
expect((await browser.elementsByCss('body title')).length).toBe(0)
expect((await browser.elementsByCss('body meta')).length).toBe(0)
})

describe('dynamic api', () => {
it('should render metadata to body', async () => {
it('should render metadata to head', async () => {
const $ = await next.render$('/dynamic-api')
expect($('head title').length).toBe(0)
expect($('body title').length).toBe(1)
expect($('head title').length).toBe(1)
expect($('body title').length).toBe(0)
})

it('should load the metadata in browser', async () => {
const browser = await next.browser('/dynamic-api')
await retry(async () => {
expect(
await browser.elementByCss('body title', { state: 'attached' }).text()
).toMatch(/Dynamic api \d+/)
expect(await browser.elementByCss('title').text()).toMatch(
/Dynamic api \d+/
)
})
})
})
Expand Down Expand Up @@ -163,10 +163,10 @@ describe('app-dir - metadata-streaming', () => {
expect($('title').text()).toBe('static page')
})

it('should determine dynamic metadata in build and render in the body', async () => {
it('should determine dynamic metadata in build and render in the head', async () => {
const $ = await next.render$('/static/partial')
expect($('title').length).toBe(1)
expect($('body title').text()).toBe('partial static page')
expect($('head title').text()).toBe('partial static page')
})

it('should still render dynamic metadata in the head for html bots', async () => {
Expand Down
Loading