Skip to content

Commit

Permalink
[metadata] handle navigation API in streaming metadata (#76156)
Browse files Browse the repository at this point in the history
### What

When streaming metadata is enabled, the whole metadata result is under
the suspense boundary and will be streamed to client if it takes some
time. Right now we only had the metadata itself rendered under the
suspense boundary. But we also need to deal with the errors, when
there're errors of `notFound()` or `redirect()` it should still be
thrown to trigger the proper error boundary.

One special case needs to be taken care of is when we passing down the
error caught during metadata from RSC layer to client layer, the
`digest` property will be lost as it's not serialized by React now.
Hence we also send both of the error itself and its digest to client and
let client compose them when both present. In this way we can keep the
original error digest of `notFound` and `redirect` navigation errors. We
don't have this case in non-streaming metadata as the metatdata
resolving was all done on the RSC side.

When streaming metadata is disabled, we should not re-throw the errors
on client, let everything works like before in the blocking way.

The metadata re-throw strategy is called `StreamingMetadataOutlet` in
the code, reviewers can focus on that.

Closes NDX-847
  • Loading branch information
huozhi authored Feb 18, 2025
1 parent bd9b11e commit 5fb8378
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 66 deletions.
60 changes: 54 additions & 6 deletions packages/next/src/lib/metadata/async-metadata.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,41 @@
'use client'

import { use, type JSX } from 'react'
import { Suspense, use } from 'react'
import { useServerInsertedMetadata } from '../../server/app-render/metadata-insertion/use-server-inserted-metadata'

function ServerInsertMetadata({ promise }: { promise: Promise<JSX.Element> }) {
export type StreamingMetadataResolvedState = {
metadata: React.ReactNode
error: unknown | null
digest: string | undefined
}

function ServerInsertMetadata({
promise,
}: {
promise: Promise<StreamingMetadataResolvedState>
}) {
// Apply use() to the metadata promise to suspend the rendering in SSR.
const metadata = use(promise)
const { metadata } = use(promise)
// Insert metadata into the HTML stream through the `useServerInsertedMetadata`
useServerInsertedMetadata(() => metadata)

return null
}

function BrowserResolvedMetadata({ promise }: { promise: Promise<any> }) {
return use(promise)
function BrowserResolvedMetadata({
promise,
}: {
promise: Promise<StreamingMetadataResolvedState>
}) {
const { metadata } = use(promise)
return metadata
}

export function AsyncMetadata({ promise }: { promise: Promise<any> }) {
export function AsyncMetadata({
promise,
}: {
promise: Promise<StreamingMetadataResolvedState>
}) {
return (
<>
{typeof window === 'undefined' ? (
Expand All @@ -27,3 +46,32 @@ export function AsyncMetadata({ promise }: { promise: Promise<any> }) {
</>
)
}

function MetadataOutlet({
promise,
}: {
promise: Promise<StreamingMetadataResolvedState>
}) {
const { error, digest } = use(promise)
if (error) {
if (digest) {
// The error will lose its original digest after passing from server layer to client layer;
// We recover the digest property here to override the React created one if original digest exists.
;(error as any).digest = digest
}
throw error
}
return null
}

export function AsyncMetadataOutlet({
promise,
}: {
promise: Promise<StreamingMetadataResolvedState>
}) {
return (
<Suspense fallback={null}>
<MetadataOutlet promise={promise} />
</Suspense>
)
}
44 changes: 36 additions & 8 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { Suspense, 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'
import type { CreateServerParamsForMetadata } from '../../server/request/params'

import { Suspense, cache, cloneElement } from 'react'
import type { StreamingMetadataResolvedState } from './async-metadata'
import {
AppleWebAppMeta,
FormatDetectionMeta,
Expand Down Expand Up @@ -37,7 +37,7 @@ import {
METADATA_BOUNDARY_NAME,
VIEWPORT_BOUNDARY_NAME,
} from './metadata-constants'
import { AsyncMetadata } from './async-metadata'
import { AsyncMetadata, AsyncMetadataOutlet } from './async-metadata'
import { isPostpone } from '../../server/lib/router-utils/is-postpone'

// Use a promise to share the status of the metadata resolving,
Expand Down Expand Up @@ -75,6 +75,7 @@ export function createMetadataComponents({
ViewportTree: React.ComponentType
getMetadataReady: () => Promise<void>
getViewportReady: () => Promise<void>
StreamingMetadataOutlet: React.ComponentType
} {
function ViewportTree() {
return (
Expand Down Expand Up @@ -145,21 +146,35 @@ export function createMetadataComponents({
)
}

async function resolveFinalMetadata() {
async function resolveFinalMetadata(): Promise<StreamingMetadataResolvedState> {
let result: React.ReactNode
let error = null
try {
return await metadata()
result = await metadata()
return {
metadata: result,
error: null,
digest: undefined,
}
} catch (metadataErr) {
error = metadataErr
if (!errorType && isHTTPAccessFallbackError(metadataErr)) {
try {
return await getNotFoundMetadata(
result = await getNotFoundMetadata(
tree,
searchParams,
getDynamicParamFromSegment,
metadataContext,
createServerParamsForMetadata,
workStore
)
return {
metadata: result,
error,
digest: (error as any)?.digest,
}
} catch (notFoundMetadataErr) {
error = notFoundMetadataErr
// In PPR rendering we still need to throw the postpone error.
// If metadata is postponed, React needs to be aware of the location of error.
if (serveStreamingMetadata && isPostpone(notFoundMetadataErr)) {
Expand All @@ -176,7 +191,11 @@ export function createMetadataComponents({
// also error in the MetadataOutlet which causes the error to
// bubble from the right position in the page to be caught by the
// appropriate boundaries
return null
return {
metadata: result,
error,
digest: (error as any)?.digest,
}
}
}
async function Metadata() {
Expand All @@ -188,7 +207,8 @@ export function createMetadataComponents({
</Suspense>
)
}
return await promise
const metadataState = await promise
return metadataState.metadata
}

Metadata.displayName = METADATA_BOUNDARY_NAME
Expand All @@ -207,11 +227,19 @@ export function createMetadataComponents({
return undefined
}

function StreamingMetadataOutlet() {
if (serveStreamingMetadata) {
return <AsyncMetadataOutlet promise={resolveFinalMetadata()} />
}
return null
}

return {
ViewportTree,
MetadataTree,
getViewportReady,
getMetadataReady,
StreamingMetadataOutlet,
}
}

Expand Down
93 changes: 51 additions & 42 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,13 @@ function createDivergedMetadataComponents(
serveStreamingMetadata: boolean
): {
StaticMetadata: React.ComponentType<{}>
StreamingMetadata: React.ComponentType<{}>
StreamingMetadata: React.ComponentType<{}> | null
} {
function EmptyMetadata() {
return null
}
const StreamingMetadata: React.ComponentType<{}> = serveStreamingMetadata
? Metadata
: EmptyMetadata
const StreamingMetadata: React.ComponentType<{}> | null =
serveStreamingMetadata ? Metadata : null

const StaticMetadata: React.ComponentType<{}> = serveStreamingMetadata
? EmptyMetadata
Expand Down Expand Up @@ -490,23 +489,28 @@ async function generateDynamicRSCPayload(
const preloadCallbacks: PreloadCallbacks = []

const searchParams = createServerSearchParamsForMetadata(query, workStore)
const { ViewportTree, MetadataTree, getViewportReady, getMetadataReady } =
createMetadataComponents({
tree: loaderTree,
searchParams,
metadataContext: createTrackedMetadataContext(
url.pathname,
ctx.renderOpts,
workStore
),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createServerParamsForMetadata,
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata,
})
const {
ViewportTree,
MetadataTree,
getViewportReady,
getMetadataReady,
StreamingMetadataOutlet,
} = createMetadataComponents({
tree: loaderTree,
searchParams,
metadataContext: createTrackedMetadataContext(
url.pathname,
ctx.renderOpts,
workStore
),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createServerParamsForMetadata,
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata,
})

const { StreamingMetadata, StaticMetadata } =
createDivergedMetadataComponents(() => {
Expand Down Expand Up @@ -540,6 +544,7 @@ async function generateDynamicRSCPayload(
getMetadataReady,
preloadCallbacks,
StreamingMetadata,
StreamingMetadataOutlet,
})
).map((path) => path.slice(1)) // remove the '' (root) segment
}
Expand Down Expand Up @@ -801,24 +806,29 @@ async function getRSCPayload(
const serveStreamingMetadata = getServeStreamingMetadata(ctx)

const searchParams = createServerSearchParamsForMetadata(query, workStore)
const { ViewportTree, MetadataTree, getViewportReady, getMetadataReady } =
createMetadataComponents({
tree,
errorType: is404 ? 'not-found' : undefined,
searchParams,
metadataContext: createTrackedMetadataContext(
url.pathname,
ctx.renderOpts,
workStore
),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createServerParamsForMetadata,
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata: serveStreamingMetadata,
})
const {
ViewportTree,
MetadataTree,
getViewportReady,
getMetadataReady,
StreamingMetadataOutlet,
} = createMetadataComponents({
tree,
errorType: is404 ? 'not-found' : undefined,
searchParams,
metadataContext: createTrackedMetadataContext(
url.pathname,
ctx.renderOpts,
workStore
),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createServerParamsForMetadata,
workStore,
MetadataBoundary,
ViewportBoundary,
serveStreamingMetadata,
})

const preloadCallbacks: PreloadCallbacks = []

Expand All @@ -844,6 +854,7 @@ async function getRSCPayload(
preloadCallbacks,
authInterrupts: ctx.renderOpts.experimental.authInterrupts,
StreamingMetadata,
StreamingMetadataOutlet,
})

// When the `vary` response header is present with `Next-URL`, that means there's a chance
Expand Down Expand Up @@ -987,9 +998,7 @@ async function getErrorRSCPayload(
const seedData: CacheNodeSeedData = [
initialTree[0],
<html id="__next_error__">
<head>
<StreamingMetadata />
</head>
<head>{StreamingMetadata ? <StreamingMetadata /> : null}</head>
<body>
{process.env.NODE_ENV !== 'production' && err ? (
<template
Expand Down
14 changes: 10 additions & 4 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function createComponentTree(props: {
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}>
StreamingMetadata: React.ComponentType<{}> | null
StreamingMetadataOutlet: React.ComponentType
}): Promise<CacheNodeSeedData> {
return getTracer().trace(
NextNodeServerSpan.createComponentTree,
Expand Down Expand Up @@ -77,6 +78,7 @@ async function createComponentTreeInternal({
preloadCallbacks,
authInterrupts,
StreamingMetadata,
StreamingMetadataOutlet,
}: {
loaderTree: LoaderTree
parentParams: Params
Expand All @@ -90,7 +92,8 @@ async function createComponentTreeInternal({
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}>
StreamingMetadata: React.ComponentType<{}> | null
StreamingMetadataOutlet: React.ComponentType
}): Promise<CacheNodeSeedData> {
const {
renderOpts: { nextConfigOutput, experimental },
Expand Down Expand Up @@ -395,7 +398,9 @@ async function createComponentTreeInternal({
// Only render metadata on the actual SSR'd segment not the `default` segment,
// as it's used as a placeholder for navigation.
const metadata =
actualSegment !== DEFAULT_SEGMENT_KEY ? <StreamingMetadata /> : undefined
actualSegment !== DEFAULT_SEGMENT_KEY && StreamingMetadata ? (
<StreamingMetadata />
) : undefined

const notFoundElement = NotFound ? (
<>
Expand Down Expand Up @@ -517,6 +522,7 @@ async function createComponentTreeInternal({
preloadCallbacks,
authInterrupts,
StreamingMetadata,
StreamingMetadataOutlet,
})

childCacheNodeSeedData = seedData
Expand Down Expand Up @@ -578,7 +584,6 @@ async function createComponentTreeInternal({
}

const Component = MaybeComponent

// If force-dynamic is used and the current render supports postponing, we
// replace it with a node that will postpone the render. This ensures that the
// postpone is invoked during the react render phase and not during the next
Expand Down Expand Up @@ -705,6 +710,7 @@ async function createComponentTreeInternal({
<OutletBoundary>
<MetadataOutlet ready={getViewportReady} />
<MetadataOutlet ready={getMetadataReady} />
<StreamingMetadataOutlet />
</OutletBoundary>
</React.Fragment>,
parallelRouteCacheNodeSeedData,
Expand Down
Loading

0 comments on commit 5fb8378

Please sign in to comment.