diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx
index 475ecd480d3540..86fbf614fb359d 100644
--- a/packages/next/src/lib/metadata/metadata.tsx
+++ b/packages/next/src/lib/metadata/metadata.tsx
@@ -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'
@@ -57,7 +57,6 @@ export function createMetadataComponents({
getDynamicParamFromSegment,
errorType,
workStore,
- serveStreamingMetadata,
}: {
tree: LoaderTree
pathname: string
@@ -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
. Therefore, we always await metadata to ensure
+ // it's included in the initial HTML where React can properly hoist it to .
+ async function Viewport() {
+ const viewportTags = await getResolvedViewport(
tree,
searchParams,
getDynamicParamFromSegment,
@@ -107,17 +110,12 @@ export function createMetadataComponents({
return null
})
- return (
-
- {/* @ts-expect-error -- Promise not considered a valid child even though it is */}
- {pendingViewportTags}
-
- )
+ return {viewportTags}
}
Viewport.displayName = 'Next.Viewport'
- function Metadata() {
- const pendingMetadataTags = getResolvedMetadata(
+ async function Metadata() {
+ const metadataTags = await getResolvedMetadata(
tree,
pathnameForMetadata,
searchParams,
@@ -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 (
-
- {/* @ts-expect-error -- Promise not considered a valid child even though it is */}
- {pendingMetadataTags}
-
- )
- }
- return (
-
-
-
- {/* @ts-expect-error -- Promise not considered a valid child even though it is */}
- {pendingMetadataTags}
-
-
-
- )
+ return {metadataTags}
}
Metadata.displayName = 'Next.Metadata'
@@ -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 {pendingOutlet}
- }
- return (
-
- {pendingOutlet}
-
- )
+ return {pendingOutlet}
}
MetadataOutlet.displayName = 'Next.MetadataOutlet'
diff --git a/test/e2e/app-dir/metadata-icons/metadata-icons.test.ts b/test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
index 1adba07b532280..3ac677f48bb2db 100644
--- a/test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
+++ b/test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
@@ -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 () => {
@@ -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')
diff --git a/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts b/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts
index efeadf934e4a33..a91525cee9c86b 100644
--- a/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts
+++ b/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts
@@ -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
@@ -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')
})
}
)
diff --git a/test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts b/test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
index 03d635ad976e1f..31b12645c4cd4a 100644
--- a/test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
+++ b/test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
@@ -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 () => {
diff --git a/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts b/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts
index 0d14f72f809ea4..d15017e796c890 100644
--- a/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts
+++ b/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts
@@ -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 {
@@ -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')
})
})
diff --git a/test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts b/test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts
index 88e2d7d48107f4..1c33607c9e5103 100644
--- a/test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts
+++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts
@@ -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
@@ -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) {
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 e588b424b35e84..e95875e3296621 100644
--- a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
+++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
@@ -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 () => {
@@ -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+/
+ )
})
})
})
@@ -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 () => {