Skip to content

Commit

Permalink
Update metadata ready tracking (reland) (#68342)
Browse files Browse the repository at this point in the history
This relands #67929 with an additional fix for unhandled rejection

The extra fix was to add a throwaway catch handler on the promise
representing the pending metadata render. We don't actually await this
promise until later and if it rejects too early it will result in an
unhandled promise rejection
  • Loading branch information
gnoff authored Jul 30, 2024
1 parent 916306e commit f02ad3c
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 96 deletions.
Binary file added packages/next/next-15.0.0-canary.93.tgz
Binary file not shown.
203 changes: 124 additions & 79 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ import type {
ResolvedMetadata,
ResolvedViewport,
} from './types/metadata-interface'
import {
createDefaultMetadata,
createDefaultViewport,
} from './default-metadata'
import { isNotFoundError } from '../../client/components/not-found'
import type { MetadataContext } from './types/resolvers'

Expand Down Expand Up @@ -70,96 +66,145 @@ export function createMetadataComponents({
createDynamicallyTrackedSearchParams: (
searchParams: ParsedUrlQuery
) => ParsedUrlQuery
}): [React.ComponentType, React.ComponentType] {
let resolve: (value: Error | undefined) => void | undefined
// Only use promise.resolve here to avoid unhandled rejections
const metadataErrorResolving = new Promise<Error | undefined>((res) => {
resolve = res
})
}): [React.ComponentType, () => Promise<void>] {
let currentMetadataReady:
| null
| (Promise<void> & {
status?: string
value?: unknown
}) = null

async function MetadataTree() {
const defaultMetadata = createDefaultMetadata()
const defaultViewport = createDefaultViewport()
let metadata: ResolvedMetadata | undefined = defaultMetadata
let viewport: ResolvedViewport | undefined = defaultViewport
let error: any
const errorMetadataItem: [null, null, null] = [null, null, null]
const errorConvention = errorType === 'redirect' ? undefined : errorType
const searchParams = createDynamicallyTrackedSearchParams(query)

const [resolvedError, resolvedMetadata, resolvedViewport] =
await resolveMetadata({
tree,
parentParams: {},
metadataItems: [],
errorMetadataItem,
searchParams,
getDynamicParamFromSegment,
errorConvention,
metadataContext,
})
if (!resolvedError) {
viewport = resolvedViewport
metadata = resolvedMetadata
resolve(undefined)
} else {
error = resolvedError
// If a not-found error is triggered during metadata resolution, we want to capture the metadata
// for the not-found route instead of whatever triggered the error. For all error types, we resolve an
// error, which will cause the outlet to throw it so it'll be handled by an error boundary
// (either an actual error, or an internal error that renders UI such as the NotFoundBoundary).
if (!errorType && isNotFoundError(resolvedError)) {
const [notFoundMetadataError, notFoundMetadata, notFoundViewport] =
await resolveMetadata({
tree,
parentParams: {},
metadataItems: [],
errorMetadataItem,
searchParams,
getDynamicParamFromSegment,
errorConvention: 'not-found',
metadataContext,
})
viewport = notFoundViewport
metadata = notFoundMetadata
error = notFoundMetadataError || error
}
resolve(error)
}
const pendingMetadata = getResolvedMetadata(
tree,
query,
getDynamicParamFromSegment,
metadataContext,
createDynamicallyTrackedSearchParams,
errorType
)

const elements = MetaFilter([
ViewportMeta({ viewport: viewport }),
BasicMeta({ metadata }),
AlternatesMetadata({ alternates: metadata.alternates }),
ItunesMeta({ itunes: metadata.itunes }),
FacebookMeta({ facebook: metadata.facebook }),
FormatDetectionMeta({ formatDetection: metadata.formatDetection }),
VerificationMeta({ verification: metadata.verification }),
AppleWebAppMeta({ appleWebApp: metadata.appleWebApp }),
OpenGraphMetadata({ openGraph: metadata.openGraph }),
TwitterMetadata({ twitter: metadata.twitter }),
AppLinksMeta({ appLinks: metadata.appLinks }),
IconsMetadata({ icons: metadata.icons }),
])
// We construct this instrumented promise to allow React.use to synchronously unwrap
// it if it has already settled.
const metadataReady: Promise<void> & { status: string; value: unknown } =
pendingMetadata.then(
([error]) => {
if (error) {
metadataReady.status = 'rejected'
metadataReady.value = error
throw error
}
metadataReady.status = 'fulfilled'
metadataReady.value = undefined
},
(error) => {
metadataReady.status = 'rejected'
metadataReady.value = error
throw error
}
) as Promise<void> & { status: string; value: unknown }
metadataReady.status = 'pending'
currentMetadataReady = metadataReady
// We aren't going to await this promise immediately but if it rejects early we don't
// want unhandled rejection errors so we attach a throwaway catch handler.
metadataReady.catch(() => {})

if (appUsingSizeAdjustment) elements.push(<meta name="next-size-adjust" />)
// We ignore any error from metadata here because it needs to be thrown from within the Page
// not where the metadata itself is actually rendered
const [, elements] = await pendingMetadata

return (
<>
{elements.map((el, index) => {
return React.cloneElement(el as React.ReactElement, { key: index })
})}
{appUsingSizeAdjustment ? <meta name="next-size-adjust" /> : null}
</>
)
}

async function MetadataOutlet() {
const error = await metadataErrorResolving
if (error) {
throw error
function getMetadataReady() {
return Promise.resolve().then(() => {
if (currentMetadataReady) {
return currentMetadataReady
}
throw new Error(
'getMetadataReady was called before MetadataTree rendered'
)
})
}

return [MetadataTree, getMetadataReady]
}

async function getResolvedMetadata(
tree: LoaderTree,
query: ParsedUrlQuery,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
metadataContext: MetadataContext,
createDynamicallyTrackedSearchParams: (
searchParams: ParsedUrlQuery
) => ParsedUrlQuery,
errorType?: 'not-found' | 'redirect'
): Promise<[any, Array<React.ReactNode>]> {
const errorMetadataItem: [null, null, null] = [null, null, null]
const errorConvention = errorType === 'redirect' ? undefined : errorType
const searchParams = createDynamicallyTrackedSearchParams(query)

const [error, metadata, viewport] = await resolveMetadata({
tree,
parentParams: {},
metadataItems: [],
errorMetadataItem,
searchParams,
getDynamicParamFromSegment,
errorConvention,
metadataContext,
})
if (!error) {
return [null, createMetadataElements(metadata, viewport)]
} else {
// If a not-found error is triggered during metadata resolution, we want to capture the metadata
// for the not-found route instead of whatever triggered the error. For all error types, we resolve an
// error, which will cause the outlet to throw it so it'll be handled by an error boundary
// (either an actual error, or an internal error that renders UI such as the NotFoundBoundary).
if (!errorType && isNotFoundError(error)) {
const [notFoundMetadataError, notFoundMetadata, notFoundViewport] =
await resolveMetadata({
tree,
parentParams: {},
metadataItems: [],
errorMetadataItem,
searchParams,
getDynamicParamFromSegment,
errorConvention: 'not-found',
metadataContext,
})
return [
notFoundMetadataError || error,
createMetadataElements(notFoundMetadata, notFoundViewport),
]
}
return null
return [error, []]
}
}

return [MetadataTree, MetadataOutlet]
function createMetadataElements(
metadata: ResolvedMetadata,
viewport: ResolvedViewport
) {
return MetaFilter([
ViewportMeta({ viewport: viewport }),
BasicMeta({ metadata }),
AlternatesMetadata({ alternates: metadata.alternates }),
ItunesMeta({ itunes: metadata.itunes }),
FacebookMeta({ facebook: metadata.facebook }),
FormatDetectionMeta({ formatDetection: metadata.formatDetection }),
VerificationMeta({ verification: metadata.verification }),
AppleWebAppMeta({ appleWebApp: metadata.appleWebApp }),
OpenGraphMetadata({ openGraph: metadata.openGraph }),
TwitterMetadata({ twitter: metadata.twitter }),
AppLinksMeta({ appLinks: metadata.appLinks }),
IconsMetadata({ icons: metadata.icons }),
])
}
8 changes: 4 additions & 4 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ async function generateDynamicRSCPayload(
if (!options?.skipFlight) {
const preloadCallbacks: PreloadCallbacks = []

const [MetadataTree, MetadataOutlet] = createMetadataComponents({
const [MetadataTree, getMetadataReady] = createMetadataComponents({
tree: loaderTree,
query,
metadataContext: createMetadataContext(url.pathname, ctx.renderOpts),
Expand Down Expand Up @@ -379,7 +379,7 @@ async function generateDynamicRSCPayload(
injectedFontPreloadTags: new Set(),
rootLayoutIncluded: false,
asNotFound: ctx.isNotFoundPath || options?.asNotFound,
metadataOutlet: <MetadataOutlet />,
getMetadataReady,
preloadCallbacks,
})
).map((path) => path.slice(1)) // remove the '' (root) segment
Expand Down Expand Up @@ -486,7 +486,7 @@ async function getRSCPayload(
query
)

const [MetadataTree, MetadataOutlet] = createMetadataComponents({
const [MetadataTree, getMetadataReady] = createMetadataComponents({
tree,
errorType: asNotFound ? 'not-found' : undefined,
query,
Expand All @@ -509,7 +509,7 @@ async function getRSCPayload(
injectedFontPreloadTags,
rootLayoutIncluded: false,
asNotFound: asNotFound,
metadataOutlet: <MetadataOutlet />,
getMetadataReady,
missingSlots,
preloadCallbacks,
})
Expand Down
37 changes: 28 additions & 9 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function createComponentTree(props: {
injectedJS: Set<string>
injectedFontPreloadTags: Set<string>
asNotFound?: boolean
metadataOutlet?: React.ReactNode
getMetadataReady: () => Promise<void>
ctx: AppRenderContext
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
Expand Down Expand Up @@ -64,7 +64,7 @@ async function createComponentTreeInternal({
injectedJS,
injectedFontPreloadTags,
asNotFound,
metadataOutlet,
getMetadataReady,
ctx,
missingSlots,
preloadCallbacks,
Expand All @@ -78,7 +78,7 @@ async function createComponentTreeInternal({
injectedJS: Set<string>
injectedFontPreloadTags: Set<string>
asNotFound?: boolean
metadataOutlet?: React.ReactNode
getMetadataReady: () => Promise<void>
ctx: AppRenderContext
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
Expand Down Expand Up @@ -436,10 +436,11 @@ async function createComponentTreeInternal({
injectedJS: injectedJSWithCurrentLayout,
injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout,
asNotFound,
// The metadataOutlet is responsible for throwing any errors that were caught during metadata resolution.
// We only want to render an outlet once per segment, as otherwise the error will be triggered
// multiple times causing an uncaught error.
metadataOutlet: isChildrenRouteKey ? metadataOutlet : undefined,
// getMetadataReady is used to conditionally throw. In the case of parallel routes we will have more than one page
// but we only want to throw on the first one.
getMetadataReady: isChildrenRouteKey
? getMetadataReady
: () => Promise.resolve(),
ctx,
missingSlots,
preloadCallbacks,
Expand Down Expand Up @@ -587,7 +588,7 @@ async function createComponentTreeInternal({
props.searchParams = createUntrackedSearchParams(query)
segmentElement = (
<>
{metadataOutlet}
<MetadataOutlet getReady={getMetadataReady} />
<ClientPageRoot props={props} Component={Component} />
{layerAssets}
</>
Expand All @@ -598,7 +599,7 @@ async function createComponentTreeInternal({
props.searchParams = createDynamicallyTrackedSearchParams(query)
segmentElement = (
<>
{metadataOutlet}
<MetadataOutlet getReady={getMetadataReady} />
<Component {...props} />
{layerAssets}
</>
Expand Down Expand Up @@ -632,3 +633,21 @@ async function createComponentTreeInternal({
loadingData,
]
}

async function MetadataOutlet({
getReady,
}: {
getReady: () => Promise<void> & { status?: string; value?: unknown }
}) {
const ready = getReady()
// We actually expect this to be an instrumented promise and once this file is properly
// moved to the RSC module graph we can switch to using React.use for this synchronous unwrapping.
// The synchronous unwrapping will become important with dynamic IO since we want to resolve metadata
// before anything dynamic can be triggered
if (ready.status === 'rejected') {
throw ready.value
} else if (ready.status !== 'fulfilled') {
await ready
}
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function walkTreeWithFlightRouterState({
injectedFontPreloadTags,
rootLayoutIncluded,
asNotFound,
metadataOutlet,
getMetadataReady,
ctx,
preloadCallbacks,
}: {
Expand All @@ -54,7 +54,7 @@ export async function walkTreeWithFlightRouterState({
injectedFontPreloadTags: Set<string>
rootLayoutIncluded: boolean
asNotFound?: boolean
metadataOutlet: React.ReactNode
getMetadataReady: () => Promise<void>
ctx: AppRenderContext
preloadCallbacks: PreloadCallbacks
}): Promise<FlightDataPath[]> {
Expand Down Expand Up @@ -154,7 +154,7 @@ export async function walkTreeWithFlightRouterState({
// This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too.
rootLayoutIncluded,
asNotFound,
metadataOutlet,
getMetadataReady,
preloadCallbacks,
}
)
Expand Down Expand Up @@ -215,7 +215,7 @@ export async function walkTreeWithFlightRouterState({
injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout,
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
asNotFound,
metadataOutlet,
getMetadataReady,
preloadCallbacks,
})

Expand Down

0 comments on commit f02ad3c

Please sign in to comment.