diff --git a/packages/next/src/lib/metadata/metadata-context.tsx b/packages/next/src/lib/metadata/metadata-context.tsx new file mode 100644 index 0000000000000..d3da6339a1018 --- /dev/null +++ b/packages/next/src/lib/metadata/metadata-context.tsx @@ -0,0 +1,46 @@ +import type { AppRenderContext } from '../../server/app-render/app-render' +import type { MetadataContext } from './types/resolvers' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' + +export function createMetadataContext( + pathname: string, + renderOpts: AppRenderContext['renderOpts'] +): MetadataContext { + return { + pathname, + trailingSlash: renderOpts.trailingSlash, + isStandaloneMode: renderOpts.nextConfigOutput === 'standalone', + } +} + +export function createTrackedMetadataContext( + pathname: string, + renderOpts: AppRenderContext['renderOpts'], + staticGenerationStore: StaticGenerationStore | null +): MetadataContext { + return { + // Use the regular metadata context, but we trap the pathname access. + ...createMetadataContext(pathname, renderOpts), + + // Setup the trap around the pathname access so we can track when the + // pathname is accessed while resolving metadata which would indicate it's + // being used to resolve a relative URL. If that's the case, we don't want + // to provide it, and instead we should error. + get pathname() { + if ( + staticGenerationStore && + staticGenerationStore.isStaticGeneration && + staticGenerationStore.fallbackRouteParams && + staticGenerationStore.fallbackRouteParams.size > 0 + ) { + trackFallbackParamAccessed( + staticGenerationStore, + 'metadata relative url resolving' + ) + } + + return pathname + }, + } +} diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index bc982d5e66ab4..35d52fb6b902d 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -1,12 +1,9 @@ import type { ParsedUrlQuery } from 'querystring' -import type { - AppRenderContext, - GetDynamicParamFromSegment, -} from '../../server/app-render/app-render' +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 React from 'react' +import { cache, cloneElement } from 'react' import { AppleWebAppMeta, FormatDetectionMeta, @@ -23,7 +20,11 @@ import { AppLinksMeta, } from './generate/opengraph' import { IconsMetadata } from './generate/icons' -import { resolveMetadata } from './resolve-metadata' +import { + resolveMetadataItems, + accumulateMetadata, + accumulateViewport, +} from './resolve-metadata' import { MetaFilter } from './generate/meta' import type { ResolvedMetadata, @@ -32,49 +33,6 @@ import type { import { isNotFoundError } from '../../client/components/not-found' import type { MetadataContext } from './types/resolvers' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' -import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' - -export function createMetadataContext( - pathname: string, - renderOpts: AppRenderContext['renderOpts'] -): MetadataContext { - return { - pathname, - trailingSlash: renderOpts.trailingSlash, - isStandaloneMode: renderOpts.nextConfigOutput === 'standalone', - } -} - -export function createTrackedMetadataContext( - pathname: string, - renderOpts: AppRenderContext['renderOpts'], - staticGenerationStore: StaticGenerationStore | null -): MetadataContext { - return { - // Use the regular metadata context, but we trap the pathname access. - ...createMetadataContext(pathname, renderOpts), - - // Setup the trap around the pathname access so we can track when the - // pathname is accessed while resolving metadata which would indicate it's - // being used to resolve a relative URL. If that's the case, we don't want - // to provide it, and instead we should error. - get pathname() { - if ( - staticGenerationStore && - staticGenerationStore.isStaticGeneration && - staticGenerationStore.fallbackRouteParams && - staticGenerationStore.fallbackRouteParams.size > 0 - ) { - trackFallbackParamAccessed( - staticGenerationStore, - 'metadata relative url resolving' - ) - } - - return pathname - }, - } -} // Use a promise to share the status of the metadata resolving, // returning two components `MetadataTree` and `MetadataOutlet` @@ -101,79 +59,97 @@ export function createMetadataComponents({ createServerParamsForMetadata: CreateServerParamsForMetadata staticGenerationStore: StaticGenerationStore }): [React.ComponentType, () => Promise] { - let currentMetadataReady: - | null - | (Promise & { - status?: string - value?: unknown - }) = null - - async function MetadataTree() { - const pendingMetadata = getResolvedMetadata( + function MetadataRoot() { + return ( + <> + + + {appUsingSizeAdjustment ? : null} + + ) + } + + async function viewport() { + return getResolvedViewport( tree, searchParams, getDynamicParamFromSegment, - metadataContext, createServerParamsForMetadata, staticGenerationStore, errorType ) + } - // We instrument the promise compatible with React. This isn't necessary but we can - // perform a similar trick in synchronously unwrapping in the outlet component to avoid - // ticking a new microtask unecessarily - const metadataReady: Promise & { 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 & { 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(() => {}) - - // 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 + async function Viewport() { + try { + return await viewport() + } catch (error) { + if (!errorType && isNotFoundError(error)) { + try { + return await getNotFoundViewport( + tree, + searchParams, + getDynamicParamFromSegment, + createServerParamsForMetadata, + staticGenerationStore + ) + } catch {} + } + // We don't actually want to error in this component. We will + // 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 ( - <> - {elements.map((el, index) => { - return React.cloneElement(el as React.ReactElement, { key: index }) - })} - {appUsingSizeAdjustment ? : null} - + async function metadata() { + return getResolvedMetadata( + tree, + searchParams, + getDynamicParamFromSegment, + metadataContext, + createServerParamsForMetadata, + staticGenerationStore, + errorType ) } - function getMetadataReady() { - return Promise.resolve().then(() => { - if (currentMetadataReady) { - return currentMetadataReady + async function Metadata() { + try { + return await metadata() + } catch (error) { + if (!errorType && isNotFoundError(error)) { + try { + return await getNotFoundMetadata( + tree, + searchParams, + getDynamicParamFromSegment, + metadataContext, + createServerParamsForMetadata, + staticGenerationStore + ) + } catch {} } - throw new Error( - 'getMetadataReady was called before MetadataTree rendered' - ) - }) + // We don't actually want to error in this component. We will + // 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 + } + } + + async function getMetadataAndViewportReady(): Promise { + await viewport() + await metadata() + return undefined } - return [MetadataTree, getMetadataReady] + return [MetadataRoot, getMetadataAndViewportReady] } -async function getResolvedMetadata( +const getResolvedMetadata = cache(getResolvedMetadataImpl) +async function getResolvedMetadataImpl( tree: LoaderTree, searchParams: Promise, getDynamicParamFromSegment: GetDynamicParamFromSegment, @@ -181,58 +157,123 @@ async function getResolvedMetadata( createServerParamsForMetadata: CreateServerParamsForMetadata, staticGenerationStore: StaticGenerationStore, errorType?: 'not-found' | 'redirect' -): Promise<[any, Array]> { - const errorMetadataItem: [null, null, null] = [null, null, null] +): Promise { const errorConvention = errorType === 'redirect' ? undefined : errorType - const [error, metadata, viewport] = await resolveMetadata({ + const metadataItems = await resolveMetadataItems( tree, - parentParams: {}, - metadataItems: [], - errorMetadataItem, searchParams, + errorConvention, getDynamicParamFromSegment, + createServerParamsForMetadata, + staticGenerationStore + ) + const elements: Array = createMetadataElements( + await accumulateMetadata(metadataItems, metadataContext) + ) + return ( + <> + {elements.map((el, index) => { + return cloneElement(el as React.ReactElement, { key: index }) + })} + + ) +} + +const getNotFoundMetadata = cache(getNotFoundMetadataImpl) +async function getNotFoundMetadataImpl( + tree: LoaderTree, + searchParams: Promise, + getDynamicParamFromSegment: GetDynamicParamFromSegment, + metadataContext: MetadataContext, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore +): Promise { + const notFoundErrorConvention = 'not-found' + const notFoundMetadataItems = await resolveMetadataItems( + tree, + searchParams, + notFoundErrorConvention, + getDynamicParamFromSegment, + createServerParamsForMetadata, + staticGenerationStore + ) + + const elements: Array = createMetadataElements( + await accumulateMetadata(notFoundMetadataItems, metadataContext) + ) + return ( + <> + {elements.map((el, index) => { + return cloneElement(el as React.ReactElement, { key: index }) + })} + + ) +} + +const getResolvedViewport = cache(getResolvedViewportImpl) +async function getResolvedViewportImpl( + tree: LoaderTree, + searchParams: Promise, + getDynamicParamFromSegment: GetDynamicParamFromSegment, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore, + errorType?: 'not-found' | 'redirect' +): Promise { + const errorConvention = errorType === 'redirect' ? undefined : errorType + + const metadataItems = await resolveMetadataItems( + tree, + searchParams, errorConvention, - metadataContext, + getDynamicParamFromSegment, createServerParamsForMetadata, - staticGenerationStore, - }) - 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, - createServerParamsForMetadata, - staticGenerationStore, - }) - return [ - notFoundMetadataError || error, - createMetadataElements(notFoundMetadata, notFoundViewport), - ] - } - return [error, []] - } + staticGenerationStore + ) + const elements: Array = createViewportElements( + await accumulateViewport(metadataItems) + ) + return ( + <> + {elements.map((el, index) => { + return cloneElement(el as React.ReactElement, { key: index }) + })} + + ) } -function createMetadataElements( - metadata: ResolvedMetadata, - viewport: ResolvedViewport -) { +const getNotFoundViewport = cache(getNotFoundViewportImpl) +async function getNotFoundViewportImpl( + tree: LoaderTree, + searchParams: Promise, + getDynamicParamFromSegment: GetDynamicParamFromSegment, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore +): Promise { + const notFoundErrorConvention = 'not-found' + const notFoundMetadataItems = await resolveMetadataItems( + tree, + searchParams, + notFoundErrorConvention, + getDynamicParamFromSegment, + createServerParamsForMetadata, + staticGenerationStore + ) + + const elements: Array = createViewportElements( + await accumulateViewport(notFoundMetadataItems) + ) + return ( + <> + {elements.map((el, index) => { + return cloneElement(el as React.ReactElement, { key: index }) + })} + + ) +} + +function createMetadataElements(metadata: ResolvedMetadata) { return MetaFilter([ - ViewportMeta({ viewport: viewport }), BasicMeta({ metadata }), AlternatesMetadata({ alternates: metadata.alternates }), ItunesMeta({ itunes: metadata.itunes }), @@ -246,3 +287,7 @@ function createMetadataElements( IconsMetadata({ icons: metadata.icons }), ]) } + +function createViewportElements(viewport: ResolvedViewport) { + return MetaFilter([ViewportMeta({ viewport: viewport })]) +} diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 0a8c490bd1181..d91383371f7b7 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -21,6 +21,10 @@ import type { import type { ParsedUrlQuery } from 'querystring' import type { StaticMetadata } from './types/icons' +// eslint-disable-next-line import/no-extraneous-dependencies +import 'server-only' + +import { cache } from 'react' import { createDefaultMetadata, createDefaultViewport, @@ -417,7 +421,7 @@ async function resolveStaticMetadata( } // [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata] -export async function collectMetadata({ +async function collectMetadata({ tree, metadataItems, errorMetadataItem, @@ -477,32 +481,50 @@ export async function collectMetadata({ } } -export async function resolveMetadataItems({ - tree, - parentParams, - metadataItems, - errorMetadataItem, - treePrefix = [], - getDynamicParamFromSegment, - searchParams, - errorConvention, - createServerParamsForMetadata, - staticGenerationStore, -}: { - tree: LoaderTree - parentParams: Params - metadataItems: MetadataItems - errorMetadataItem: MetadataItems[number] +const cachedResolveMetadataItems = cache(resolveMetadataItems) +export { cachedResolveMetadataItems as resolveMetadataItems } +async function resolveMetadataItems( + tree: LoaderTree, + searchParams: Promise, + errorConvention: 'not-found' | undefined, + getDynamicParamFromSegment: GetDynamicParamFromSegment, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore +) { + const parentParams = {} + const metadataItems: MetadataItems = [] + const errorMetadataItem: MetadataItems[number] = [null, null, null] + const treePrefix = undefined + return resolveMetadataItemsImpl( + metadataItems, + tree, + treePrefix, + parentParams, + searchParams, + errorConvention, + errorMetadataItem, + getDynamicParamFromSegment, + createServerParamsForMetadata, + staticGenerationStore + ) +} + +async function resolveMetadataItemsImpl( + metadataItems: MetadataItems, + tree: LoaderTree, /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ - treePrefix?: string[] - getDynamicParamFromSegment: GetDynamicParamFromSegment - searchParams: ParsedUrlQuery - errorConvention: 'not-found' | undefined - createServerParamsForMetadata: CreateServerParamsForMetadata + treePrefix: undefined | string[], + parentParams: Params, + searchParams: Promise, + errorConvention: 'not-found' | undefined, + errorMetadataItem: MetadataItems[number], + getDynamicParamFromSegment: GetDynamicParamFromSegment, + createServerParamsForMetadata: CreateServerParamsForMetadata, staticGenerationStore: StaticGenerationStore -}): Promise { +): Promise { const [segment, parallelRoutes, { page }] = tree - const currentTreePrefix = [...treePrefix, segment] + const currentTreePrefix = + treePrefix && treePrefix.length ? [...treePrefix, segment] : [segment] const isPage = typeof page !== 'undefined' // Handle dynamic segment params. @@ -549,18 +571,18 @@ export async function resolveMetadataItems({ for (const key in parallelRoutes) { const childTree = parallelRoutes[key] - await resolveMetadataItems({ - tree: childTree, + await resolveMetadataItemsImpl( metadataItems, - errorMetadataItem, - parentParams: currentParams, - treePrefix: currentTreePrefix, + childTree, + currentTreePrefix, + currentParams, searchParams, - getDynamicParamFromSegment, errorConvention, + errorMetadataItem, + getDynamicParamFromSegment, createServerParamsForMetadata, - staticGenerationStore, - }) + staticGenerationStore + ) } if (Object.keys(parallelRoutes).length === 0 && errorConvention) { @@ -892,51 +914,3 @@ export async function accumulateViewport( } return resolvedViewport } - -export async function resolveMetadata({ - tree, - parentParams, - metadataItems, - errorMetadataItem, - getDynamicParamFromSegment, - searchParams, - errorConvention, - metadataContext, - createServerParamsForMetadata, - staticGenerationStore, -}: { - tree: LoaderTree - parentParams: Params - metadataItems: MetadataItems - errorMetadataItem: MetadataItems[number] - /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ - treePrefix?: string[] - getDynamicParamFromSegment: GetDynamicParamFromSegment - searchParams: { [key: string]: any } - errorConvention: 'not-found' | undefined - metadataContext: MetadataContext - createServerParamsForMetadata: CreateServerParamsForMetadata - staticGenerationStore: StaticGenerationStore -}): Promise<[any, ResolvedMetadata, ResolvedViewport]> { - const resolvedMetadataItems = await resolveMetadataItems({ - tree, - parentParams, - metadataItems, - errorMetadataItem, - getDynamicParamFromSegment, - searchParams, - errorConvention, - createServerParamsForMetadata, - staticGenerationStore, - }) - let error - let metadata: ResolvedMetadata = createDefaultMetadata() - let viewport: ResolvedViewport = createDefaultViewport() - try { - viewport = await accumulateViewport(resolvedMetadataItems) - metadata = await accumulateMetadata(resolvedMetadataItems, metadataContext) - } catch (err: any) { - error = err - } - return [error, metadata, viewport] -} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 7b4f969702dc9..309e5e6766655 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -48,10 +48,9 @@ import { RSC_HEADER, } from '../../client/components/app-router-headers' import { - createMetadataComponents, createTrackedMetadataContext, createMetadataContext, -} from '../../lib/metadata/metadata' +} from '../../lib/metadata/metadata-context' import { withRequestStore } from '../async-storage/with-request-store' import { withStaticGenerationStore } from '../async-storage/with-static-generation-store' import { isNotFoundError } from '../../client/components/not-found' @@ -389,6 +388,7 @@ async function generateDynamicRSCPayload( tree: loaderTree, createServerSearchParamsForMetadata, createServerParamsForMetadata, + createMetadataComponents, }, getDynamicParamFromSegment, appUsingSizeAdjustment, @@ -571,6 +571,7 @@ async function getRSCPayload( GlobalError, createServerSearchParamsForMetadata, createServerParamsForMetadata, + createMetadataComponents, }, requestStore: { url }, staticGenerationStore, @@ -672,6 +673,7 @@ async function getErrorRSCPayload( GlobalError, createServerSearchParamsForMetadata, createServerParamsForMetadata, + createMetadataComponents, }, requestStore: { url }, requestId, diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index d4d2c37e4c3b8..6c59f78be36ee 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -29,6 +29,7 @@ import { } from '../request/params' import * as serverHooks from '../../client/components/hooks-server-context' import { NotFoundBoundary } from '../../client/components/not-found-boundary' +import { createMetadataComponents } from '../../lib/metadata/metadata' import { patchFetch as _patchFetch } from '../lib/patch-fetch' // not being used but needs to be included in the client manifest for /_not-found import '../../client/components/error-boundary' @@ -69,4 +70,5 @@ export { ClientSegmentRoot, NotFoundBoundary, patchFetch, + createMetadataComponents, }