Skip to content

Commit

Permalink
[metadata] initial support of streaming metadata (#74619)
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi authored Jan 9, 2025
1 parent 704a24a commit 47639cb
Show file tree
Hide file tree
Showing 15 changed files with 272 additions and 30 deletions.
1 change: 1 addition & 0 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
44 changes: 44 additions & 0 deletions packages/next/src/lib/metadata/async-metadata.tsx
Original file line number Diff line number Diff line change
@@ -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<any> }) {
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<any> }) {
return use(promise)
}

export function AsyncMetadata({ promise }: { promise: Promise<any> }) {
return (
<>
{typeof window === 'undefined' ? (
<ServerInsertMetadata promise={promise} />
) : (
<BrowserResolvedMetadata promise={promise} />
)}
</>
)
}
22 changes: 18 additions & 4 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -55,6 +56,7 @@ export function createMetadataComponents({
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata,
}: {
tree: LoaderTree
searchParams: Promise<ParsedUrlQuery>
Expand All @@ -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
Expand Down Expand Up @@ -94,7 +97,7 @@ export function createMetadataComponents({
)
}

async function viewport() {
function viewport() {
return getResolvedViewport(
tree,
searchParams,
Expand Down Expand Up @@ -129,7 +132,7 @@ export function createMetadataComponents({
}
Viewport.displayName = VIEWPORT_BOUNDARY_NAME

async function metadata() {
function metadata() {
return getResolvedMetadata(
tree,
searchParams,
Expand All @@ -141,7 +144,7 @@ export function createMetadataComponents({
)
}

async function Metadata() {
async function resolveFinalMetadata() {
try {
return await metadata()
} catch (error) {
Expand All @@ -164,10 +167,21 @@ export function createMetadataComponents({
return null
}
}
async function Metadata() {
if (serveStreamingMetadata) {
return <AsyncMetadata promise={resolveFinalMetadata()} />
}
return await resolveFinalMetadata()
}

Metadata.displayName = METADATA_BOUNDARY_NAME

async function getMetadataReady(): Promise<void> {
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
}

Expand Down
49 changes: 35 additions & 14 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,20 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
]
}

function createSuspenseyMetadata(
Metadata: React.ComponentType<{}>,
serveStreamingMetadata: boolean
): React.ComponentType<{}> {
return () =>
serveStreamingMetadata ? (
<React.Suspense fallback={null}>
<Metadata />
</React.Suspense>
) : (
<Metadata />
)
}

/**
* Returns a function that parses the dynamic segment and return the associated value.
*/
Expand Down Expand Up @@ -466,16 +480,17 @@ async function generateDynamicRSCPayload(
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata,
})

const MetadataComponent = () => {
const MetadataComponent = createSuspenseyMetadata(() => {
return (
<React.Fragment key={flightDataPathMetadataKey}>
{/* Adding requestId as react key to make metadata remount for each render */}
<MetadataTree key={requestId} />
</React.Fragment>
)
}
}, !!ctx.renderOpts.serveStreamingMetadata)

flightData = (
await walkTreeWithFlightRouterState({
Expand All @@ -484,15 +499,14 @@ async function generateDynamicRSCPayload(
parentParams: {},
flightRouterState,
// For flight, render metadata inside leaf page
rscHead: [
rscHead: (
<React.Fragment key={flightDataPathViewportKey}>
{/* noindex needs to be blocking */}
<NonIndex ctx={ctx} />
{/* Adding requestId as react key to make metadata remount for each render */}
<ViewportTree key={requestId} />
</React.Fragment>,
null,
],
</React.Fragment>
),
injectedCSS: new Set(),
injectedJS: new Set(),
injectedFontPreloadTags: new Set(),
Expand Down Expand Up @@ -770,18 +784,19 @@ async function getRSCPayload(
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata,
})

const preloadCallbacks: PreloadCallbacks = []

function MetadataComponent() {
const MetadataComponent = createSuspenseyMetadata(() => {
return (
<React.Fragment key={flightDataPathMetadataKey}>
{/* Not add requestId as react key to ensure segment prefetch could result consistently if nothing changed */}
<MetadataTree />
</React.Fragment>
)
}
}, !!ctx.renderOpts.serveStreamingMetadata)

const seedData = await createComponentTree({
ctx,
Expand Down Expand Up @@ -895,13 +910,17 @@ async function getErrorRSCPayload(
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata: !!ctx.renderOpts.serveStreamingMetadata,
})

const initialHeadMetadata = (
<React.Fragment key={flightDataPathMetadataKey}>
{/* Adding requestId as react key to make metadata remount for each render */}
<MetadataTree key={requestId} />
</React.Fragment>
const ErrorMetadataComponent = createSuspenseyMetadata(
() => (
<React.Fragment key={flightDataPathMetadataKey}>
{/* Adding requestId as react key to make metadata remount for each render */}
<MetadataTree key={requestId} />
</React.Fragment>
),
!!ctx.renderOpts.serveStreamingMetadata
)

const initialHeadViewport = (
Expand All @@ -926,7 +945,9 @@ async function getErrorRSCPayload(
const seedData: CacheNodeSeedData = [
initialTree[0],
<html id="__next_error__">
<head>{initialHeadMetadata}</head>
<head>
<ErrorMetadataComponent />
</head>
<body />
</html>,
{},
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export interface RenderOptsPartial {
assetPrefix?: string
crossOrigin?: '' | 'anonymous' | 'use-credentials' | undefined
nextFontManifest?: DeepReadonly<NextFontManifest>
isBot?: boolean
serveStreamingMetadata?: boolean
incrementalCache?: import('../lib/incremental-cache').IncrementalCache
cacheLifeProfiles?: {
[profile: string]: import('../use-cache/cache-life').CacheLife
Expand Down Expand Up @@ -215,6 +215,7 @@ export interface RenderOptsPartial {
clientSegmentCache: boolean
inlineCss: boolean
authInterrupts: boolean
streamingMetadata: boolean
}
postponed?: string

Expand Down
17 changes: 12 additions & 5 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1666,13 +1667,17 @@ export default abstract class Server<
'renderOpts'
>
): Promise<void> {
const isBotRequest = isBot(partialContext.req.headers['user-agent'] || '')
const ua = partialContext.req.headers['user-agent'] || ''
const isBotRequest = isBot(ua)

const ctx: RequestContext<ServerRequest, ServerResponse> = {
...partialContext,
renderOpts: {
...this.renderOpts,
supportsDynamicResponse: !isBotRequest,
isBot: !!isBotRequest,
serveStreamingMetadata:
this.renderOpts.experimental.streamingMetadata &&
!isHtmlLimitedBotUA(ua),
},
}
const payload = await fn(ctx)
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
serverComponentsHmrCache: z.boolean().optional(),
authInterrupts: z.boolean().optional(),
newDevOverlay: z.boolean().optional(),
streamingMetadata: z.boolean().optional(),
})
.optional(),
exportPathMap: z
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -1191,6 +1196,7 @@ export const defaultConfig: NextConfig = {
dynamicIO: false,
inlineCss: false,
newDevOverlay: false,
streamingMetadata: false,
},
bundlePagesRouterDependencies: false,
}
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export type RenderOptsPartial = {
domainLocales?: readonly DomainLocale[]
disableOptimizedLoading?: boolean
supportsDynamicResponse: boolean
isBot?: boolean
serveStreamingMetadata?: boolean
runtime?: ServerRuntime
serverComponents?: boolean
serverActions?: {
Expand Down
10 changes: 5 additions & 5 deletions packages/next/src/shared/lib/router/utils/is-bot.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
// 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
// due to how they parse the DOM. For example, they might explicitly check for metadata in the `head` tag, so we can't stream metadata tags after the `head` was sent.
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)
}
Loading

0 comments on commit 47639cb

Please sign in to comment.