diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index cd21ca4401120..b568b0ac5e8b6 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -359,6 +359,7 @@ async function exportAppImpl( clientSegmentCache: nextConfig.experimental.clientSegmentCache ?? false, inlineCss: nextConfig.experimental.inlineCss ?? false, authInterrupts: !!nextConfig.experimental.authInterrupts, + streamingMetadata: !!nextConfig.experimental.streamingMetadata, }, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, } diff --git a/packages/next/src/lib/metadata/async-metadata.tsx b/packages/next/src/lib/metadata/async-metadata.tsx new file mode 100644 index 0000000000000..65de05887ea8a --- /dev/null +++ b/packages/next/src/lib/metadata/async-metadata.tsx @@ -0,0 +1,44 @@ +'use client' + +import { use } from 'react' +import { useServerInsertedHTML } from '../../client/components/navigation' + +// This is a SSR-only version that will wait the promise of metadata to resolve +// and +function ServerInsertMetadata({ promise }: { promise: Promise }) { + let metadataToFlush: React.ReactNode = null + // Only inserted once to avoid multi insertion on re-renders + let inserted = false + + promise.then((resolvedMetadata) => { + metadataToFlush = resolvedMetadata + }) + + useServerInsertedHTML(() => { + if (metadataToFlush && !inserted) { + const flushing = metadataToFlush + metadataToFlush = null + return flushing + } + }) + + inserted = true + + return null +} + +function BrowserResolvedMetadata({ promise }: { promise: Promise }) { + return use(promise) +} + +export function AsyncMetadata({ promise }: { promise: Promise }) { + return ( + <> + {typeof window === 'undefined' ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index f817beb2143c8..2cbeebec8254d 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -37,6 +37,7 @@ import { METADATA_BOUNDARY_NAME, VIEWPORT_BOUNDARY_NAME, } from './metadata-constants' +import { AsyncMetadata } from './async-metadata' // Use a promise to share the status of the metadata resolving, // returning two components `MetadataTree` and `MetadataOutlet` @@ -55,6 +56,7 @@ export function createMetadataComponents({ workStore, MetadataBoundary, ViewportBoundary, + serveStreamingMetadata, }: { tree: LoaderTree searchParams: Promise @@ -66,6 +68,7 @@ export function createMetadataComponents({ workStore: WorkStore MetadataBoundary: (props: { children: React.ReactNode }) => React.ReactNode ViewportBoundary: (props: { children: React.ReactNode }) => React.ReactNode + serveStreamingMetadata: boolean }): { MetadataTree: React.ComponentType ViewportTree: React.ComponentType @@ -94,7 +97,7 @@ export function createMetadataComponents({ ) } - async function viewport() { + function viewport() { return getResolvedViewport( tree, searchParams, @@ -129,7 +132,7 @@ export function createMetadataComponents({ } Viewport.displayName = VIEWPORT_BOUNDARY_NAME - async function metadata() { + function metadata() { return getResolvedMetadata( tree, searchParams, @@ -141,7 +144,7 @@ export function createMetadataComponents({ ) } - async function Metadata() { + async function resolveFinalMetadata() { try { return await metadata() } catch (error) { @@ -164,10 +167,21 @@ export function createMetadataComponents({ return null } } + async function Metadata() { + if (serveStreamingMetadata) { + return + } + return await resolveFinalMetadata() + } + Metadata.displayName = METADATA_BOUNDARY_NAME async function getMetadataReady(): Promise { - await metadata() + // Only warm up metadata() call when it's blocking metadata, + // otherwise it will be fully managed by AsyncMetadata component. + if (!serveStreamingMetadata) { + await metadata() + } return undefined } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 6644170c7eb9b..eb16ab27416a9 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -314,6 +314,20 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { ] } +function createSuspenseyMetadata( + Metadata: React.ComponentType<{}>, + serveStreamingMetadata: boolean +): React.ComponentType<{}> { + return () => + serveStreamingMetadata ? ( + + + + ) : ( + + ) +} + /** * Returns a function that parses the dynamic segment and return the associated value. */ @@ -466,16 +480,17 @@ async function generateDynamicRSCPayload( workStore, MetadataBoundary, ViewportBoundary, + serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata, }) - const MetadataComponent = () => { + const MetadataComponent = createSuspenseyMetadata(() => { return ( {/* Adding requestId as react key to make metadata remount for each render */} ) - } + }, !!ctx.renderOpts.serveStreamingMetadata) flightData = ( await walkTreeWithFlightRouterState({ @@ -484,15 +499,14 @@ async function generateDynamicRSCPayload( parentParams: {}, flightRouterState, // For flight, render metadata inside leaf page - rscHead: [ + rscHead: ( {/* noindex needs to be blocking */} {/* Adding requestId as react key to make metadata remount for each render */} - , - null, - ], + + ), injectedCSS: new Set(), injectedJS: new Set(), injectedFontPreloadTags: new Set(), @@ -770,18 +784,19 @@ async function getRSCPayload( workStore, MetadataBoundary, ViewportBoundary, + serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata, }) const preloadCallbacks: PreloadCallbacks = [] - function MetadataComponent() { + const MetadataComponent = createSuspenseyMetadata(() => { return ( {/* Not add requestId as react key to ensure segment prefetch could result consistently if nothing changed */} ) - } + }, !!ctx.renderOpts.serveStreamingMetadata) const seedData = await createComponentTree({ ctx, @@ -895,13 +910,17 @@ async function getErrorRSCPayload( workStore, MetadataBoundary, ViewportBoundary, + serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata, }) - const initialHeadMetadata = ( - - {/* Adding requestId as react key to make metadata remount for each render */} - - + const ErrorMetadataComponent = createSuspenseyMetadata( + () => ( + + {/* Adding requestId as react key to make metadata remount for each render */} + + + ), + !!ctx.renderOpts.serveStreamingMetadata ) const initialHeadViewport = ( @@ -926,7 +945,9 @@ async function getErrorRSCPayload( const seedData: CacheNodeSeedData = [ initialTree[0], - {initialHeadMetadata} + + + , {}, diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index d91d27c8b5d68..8046de212cd7b 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -177,7 +177,7 @@ export interface RenderOptsPartial { assetPrefix?: string crossOrigin?: '' | 'anonymous' | 'use-credentials' | undefined nextFontManifest?: DeepReadonly - isBot?: boolean + serveStreamingMetadata?: boolean incrementalCache?: import('../lib/incremental-cache').IncrementalCache cacheLifeProfiles?: { [profile: string]: import('../use-cache/cache-life').CacheLife @@ -215,6 +215,7 @@ export interface RenderOptsPartial { clientSegmentCache: boolean inlineCss: boolean authInterrupts: boolean + streamingMetadata: boolean } postponed?: string diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 0b804b8aefbce..58b92495620d9 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -83,7 +83,7 @@ import { } from './lib/revalidate' import { execOnce } from '../shared/lib/utils' import { isBlockedPage } from './utils' -import { isBot } from '../shared/lib/router/utils/is-bot' +import { isBot, isHtmlLimitedBotUA } from '../shared/lib/router/utils/is-bot' import RenderResult from './render-result' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' @@ -594,6 +594,7 @@ export default abstract class Server< this.nextConfig.experimental.clientSegmentCache ?? false, inlineCss: this.nextConfig.experimental.inlineCss ?? false, authInterrupts: !!this.nextConfig.experimental.authInterrupts, + streamingMetadata: !!this.nextConfig.experimental.streamingMetadata, }, onInstrumentationRequestError: this.instrumentationOnRequestError.bind(this), @@ -1666,13 +1667,17 @@ export default abstract class Server< 'renderOpts' > ): Promise { - const isBotRequest = isBot(partialContext.req.headers['user-agent'] || '') + const ua = partialContext.req.headers['user-agent'] || '' + const isBotRequest = isBot(ua) + const ctx: RequestContext = { ...partialContext, renderOpts: { ...this.renderOpts, supportsDynamicResponse: !isBotRequest, - isBot: !!isBotRequest, + serveStreamingMetadata: + this.renderOpts.experimental.streamingMetadata && + !isHtmlLimitedBotUA(ua), }, } const payload = await fn(ctx) @@ -2162,7 +2167,8 @@ export default abstract class Server< if ('amp' in query && !query.amp) delete query.amp if (opts.supportsDynamicResponse === true) { - const isBotRequest = isBot(req.headers['user-agent'] || '') + const ua = req.headers['user-agent'] || '' + const isBotRequest = isBot(ua) const isSupportedDocument = typeof components.Document?.getInitialProps !== 'function' || // The built-in `Document` component also supports dynamic HTML for concurrent mode. @@ -2175,7 +2181,8 @@ export default abstract class Server< // cache if there are no dynamic data requirements opts.supportsDynamicResponse = !isSSG && !isBotRequest && !query.amp && isSupportedDocument - opts.isBot = isBotRequest + opts.serveStreamingMetadata = + opts.experimental.streamingMetadata && !isHtmlLimitedBotUA(ua) } // In development, we always want to generate dynamic HTML. diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index eb15a76cb7b43..a66526acf9e95 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -440,6 +440,7 @@ export const configSchema: zod.ZodType = z.lazy(() => serverComponentsHmrCache: z.boolean().optional(), authInterrupts: z.boolean().optional(), newDevOverlay: z.boolean().optional(), + streamingMetadata: z.boolean().optional(), }) .optional(), exportPathMap: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 62ca29ec34c32..038f5d07f2d87 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -567,6 +567,11 @@ export interface ExperimentalConfig { * Enables the new dev overlay. */ newDevOverlay?: boolean + + /** + * When enabled will cause async metadata calls to stream rather than block the render. + */ + streamingMetadata?: boolean } export type ExportPathMap = { @@ -1191,6 +1196,7 @@ export const defaultConfig: NextConfig = { dynamicIO: false, inlineCss: false, newDevOverlay: false, + streamingMetadata: false, }, bundlePagesRouterDependencies: false, } diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index b8d532a8642f3..0c6985f7681d6 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -265,7 +265,7 @@ export type RenderOptsPartial = { domainLocales?: readonly DomainLocale[] disableOptimizedLoading?: boolean supportsDynamicResponse: boolean - isBot?: boolean + serveStreamingMetadata?: boolean runtime?: ServerRuntime serverComponents?: boolean serverActions?: { diff --git a/packages/next/src/shared/lib/router/utils/is-bot.ts b/packages/next/src/shared/lib/router/utils/is-bot.ts index 69b4da34b6f90..ca1e02d20215d 100644 --- a/packages/next/src/shared/lib/router/utils/is-bot.ts +++ b/packages/next/src/shared/lib/router/utils/is-bot.ts @@ -1,5 +1,5 @@ // Bot crawler that will spin up a headless browser and execute JS -const HEADLESS_BOT_UA_RE = +const HEADLESS_BROWSER_BOT_UA_RE = /Googlebot|Google-PageRenderer|AdsBot-Google|googleweblight|Storebot-Google/i // This regex contains the bots that we need to do a blocking render for and can't safely stream the response @@ -7,14 +7,14 @@ const HEADLESS_BOT_UA_RE = const HTML_LIMITED_BOT_UA_RE = /Mediapartners-Google|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview/i -function isHeadlessBrowserBot(userAgent: string) { - return HEADLESS_BOT_UA_RE.test(userAgent) +function isHeadlessBrowserBotUA(userAgent: string) { + return HEADLESS_BROWSER_BOT_UA_RE.test(userAgent) } -function isHtmlLimitedBot(userAgent: string) { +export function isHtmlLimitedBotUA(userAgent: string) { return HTML_LIMITED_BOT_UA_RE.test(userAgent) } export function isBot(userAgent: string): boolean { - return isHeadlessBrowserBot(userAgent) || isHtmlLimitedBot(userAgent) + return isHeadlessBrowserBotUA(userAgent) || isHtmlLimitedBotUA(userAgent) } diff --git a/test/e2e/app-dir/metadata-streaming/app/layout.tsx b/test/e2e/app-dir/metadata-streaming/app/layout.tsx new file mode 100644 index 0000000000000..99857b39858a2 --- /dev/null +++ b/test/e2e/app-dir/metadata-streaming/app/layout.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link' +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + +
+ + {`to /slow`} + +
+ + {`to /`} + +
+ {children} + + + ) +} diff --git a/test/e2e/app-dir/metadata-streaming/app/page.tsx b/test/e2e/app-dir/metadata-streaming/app/page.tsx new file mode 100644 index 0000000000000..ca0a7abf01087 --- /dev/null +++ b/test/e2e/app-dir/metadata-streaming/app/page.tsx @@ -0,0 +1,12 @@ +export default function Page() { + return

index

+} + +export async function generateMetadata() { + await new Promise((resolve) => setTimeout(resolve, 3 * 1000)) + return { + title: 'index page', + } +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/app-dir/metadata-streaming/app/slow/page.tsx b/test/e2e/app-dir/metadata-streaming/app/slow/page.tsx new file mode 100644 index 0000000000000..dff3d11dcb478 --- /dev/null +++ b/test/e2e/app-dir/metadata-streaming/app/slow/page.tsx @@ -0,0 +1,21 @@ +export default function Page() { + return

slow

+} + +export async function generateMetadata() { + await new Promise((resolve) => setTimeout(resolve, 1 * 1000)) + return { + title: 'slow page', + description: 'slow page description', + generator: 'next.js', + applicationName: 'test', + referrer: 'origin-when-cross-origin', + keywords: ['next.js', 'react', 'javascript'], + authors: [{ name: 'huozhi' }], + creator: 'huozhi', + publisher: 'vercel', + robots: 'index, follow', + } +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts new file mode 100644 index 0000000000000..0c3ee190f89cd --- /dev/null +++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts @@ -0,0 +1,83 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry, createMultiDomMatcher } from 'next-test-utils' + +describe('metadata-streaming', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should load the page without delayed metadata', async () => { + const $ = await next.render$('/') + expect($('title').length).toBe(0) + }) + + it('should still load viewport meta tags even if metadata is delayed', async () => { + const $ = await next.render$('/slow') + + expect($('meta[name="viewport"]').attr('content')).toBe( + 'width=device-width, initial-scale=1' + ) + expect($('meta[charset]').attr('charset')).toBe('utf-8') + }) + + it('should render the metadata in the browser', async () => { + const browser = await next.browser('/') + await retry(async () => { + expect(await browser.elementByCss('title').text()).toBe('index page') + }) + }) + + it('should load the initial html without slow metadata during navigation', async () => { + // navigate from / to /slow, the metadata should be empty first, e.g. no title. + // then the metadata should be loaded after few seconds. + const browser = await next.browser('/') + await browser.elementByCss('#to-slow').click() + + await retry(async () => { + expect(await browser.elementByCss('title').text()).toBe('slow page') + const matchMultiDom = createMultiDomMatcher(browser) + + await matchMultiDom('meta', 'name', 'content', { + description: 'slow page description', + generator: 'next.js', + 'application-name': 'test', + referrer: 'origin-when-cross-origin', + keywords: 'next.js,react,javascript', + author: ['huozhi'], + viewport: 'width=device-width, initial-scale=1', + creator: 'huozhi', + publisher: 'vercel', + robots: 'index, follow', + }) + }) + }) + + it('should send the blocking response for html limited bots', async () => { + const $ = await next.render$( + '/', + undefined, // no query + { + headers: { + 'user-agent': 'Twitterbot', + }, + } + ) + expect(await $('title').text()).toBe('index page') + }) + + it('should send streaming response for headless browser bots', async () => { + const browser = await next.browser('/') + await retry(async () => { + expect(await browser.elementByCss('title').text()).toBe('index page') + }) + }) + + it('should not insert metadata twice or inject into body', async () => { + const browser = await next.browser('/slow') + + // each metadata should be inserted only once + + expect(await browser.hasElementByCssSelector('body meta')).toBe(false) + expect(await browser.hasElementByCssSelector('body title')).toBe(false) + }) +}) diff --git a/test/e2e/app-dir/metadata-streaming/next.config.js b/test/e2e/app-dir/metadata-streaming/next.config.js new file mode 100644 index 0000000000000..427a5b2dcb098 --- /dev/null +++ b/test/e2e/app-dir/metadata-streaming/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + streamingMetadata: true, + }, +} + +module.exports = nextConfig