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 ( - - ) + 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 () => {