diff --git a/packages/next/src/lib/metadata/metadata-resolution-primitives.ts b/packages/next/src/lib/metadata/metadata-resolution-primitives.ts new file mode 100644 index 000000000000..7e1fcb919fa2 --- /dev/null +++ b/packages/next/src/lib/metadata/metadata-resolution-primitives.ts @@ -0,0 +1,738 @@ +import type { + Metadata, + ResolvedMetadata, + ResolvedViewport, + ResolvingMetadata, + ResolvingViewport, + Viewport, + WithStringifiedURLs, +} from './types/metadata-interface' +import type { MetadataImageModule } from '../../build/webpack/loaders/metadata/types' +import type { Twitter } from './types/twitter-types' +import type { OpenGraph } from './types/opengraph-types' +import type { AppDirModules } from '../../build/webpack/loaders/next-app-loader' +import type { MetadataContext } from './types/resolvers' +import type { + AbsoluteTemplateString, + IconDescriptor, + ResolvedIcons, +} from './types/metadata-types' +import type { StaticMetadata } from './types/icons' +import type { Params } from '../../server/request/params' +import type { SearchParams } from '../../server/request/search-params' + +// eslint-disable-next-line import/no-extraneous-dependencies +import 'server-only' + +import { resolveOpenGraph, resolveTwitter } from './resolvers/resolve-opengraph' +import { resolveTitle } from './resolvers/resolve-title' +import { resolveAsArrayOrUndefined } from './generate/utils' +import { + resolveAlternates, + resolveAppleWebApp, + resolveAppLinks, + resolveRobots, + resolveThemeColor, + resolveVerification, + resolveItunes, + resolveFacebook, + resolvePagination, +} from './resolvers/resolve-basics' +import { resolveIcons } from './resolvers/resolve-icons' +import { getTracer } from '../../server/lib/trace/tracer' +import { ResolveMetadataSpan } from '../../server/lib/trace/constants' +import type { MetadataBaseURL } from './resolvers/resolve-url' +import { isUseCacheFunction } from '../client-and-server-references' +import type { + UseCacheLayoutProps, + UseCachePageProps, +} from '../../server/use-cache/use-cache-wrapper' + +export type StaticIcons = Pick + +export type Resolved = T extends Metadata + ? ResolvedMetadata + : ResolvedViewport + +export type InstrumentedResolver = (( + parent: Promise> +) => TData | Promise) & { + $$original: ( + props: unknown, + parent: Promise> + ) => TData | Promise +} +export type MetadataResolver = InstrumentedResolver +export type ViewportResolver = InstrumentedResolver + +export type MetadataErrorType = 'not-found' | 'forbidden' | 'unauthorized' + +export type MetadataItems = Array< + [Metadata | MetadataResolver | null, StaticMetadata] +> + +export type ViewportItems = Array + +type WithSelectedTitle = T extends { title: AbsoluteTemplateString } + ? Omit & { title: string } + : T + +/** + * Metadata that has finished route-level resolution and post-processing. It + * contains only values that can be turned into metadata elements; it is never + * used as the parent of another metadata resolver. + */ +export type SelectedMetadata = Omit< + ResolvedMetadata, + | 'metadataBase' + | 'title' + | 'openGraph' + | 'twitter' + | 'themeColor' + | 'colorScheme' + | 'viewport' +> & { + title: string | null + openGraph: WithSelectedTitle< + NonNullable + > | null + twitter: WithSelectedTitle> | null +} + +export type TitleTemplates = { + title: string | null + twitter: string | null + openGraph: string | null +} + +export type BuildState = { + warnings: Set +} + +export type LayoutProps = { + params: Promise +} + +export type PageProps = { + params: Promise + searchParams: Promise +} + +export type SegmentProps = LayoutProps | PageProps +export type UseCacheSegmentProps = UseCacheLayoutProps | UseCachePageProps + +export function isFavicon(icon: IconDescriptor | undefined): boolean { + if (!icon) { + return false + } + + // turbopack appends a hash to all images + return ( + (icon.url === '/favicon.ico' || + icon.url.toString().startsWith('/favicon.ico?')) && + icon.type === 'image/x-icon' + ) +} + +export function convertUrlsToStrings(input: T): WithStringifiedURLs { + if (input instanceof URL) { + return input.toString() as unknown as WithStringifiedURLs + } else if (Array.isArray(input)) { + return input.map((item) => + convertUrlsToStrings(item) + ) as WithStringifiedURLs + } else if (input && typeof input === 'object') { + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + result[key] = convertUrlsToStrings(value) + } + return result as WithStringifiedURLs + } + return input as WithStringifiedURLs +} + +function normalizeMetadataBase(metadataBase: string | URL | null): URL | null { + if (typeof metadataBase === 'string') { + try { + metadataBase = new URL(metadataBase) + } catch { + throw new Error(`metadataBase is not a valid URL: ${metadataBase}`) + } + } + return metadataBase +} + +async function mergeStaticMetadata( + metadataBase: MetadataBaseURL, + source: Metadata | null, + target: ResolvedMetadata, + staticFilesMetadata: StaticMetadata, + metadataContext: MetadataContext, + titleTemplates: TitleTemplates, + leafSegmentStaticIcons: StaticIcons, + pathname: Promise +): Promise { + if (!staticFilesMetadata) return target + const { icon, apple, openGraph, twitter, manifest } = staticFilesMetadata + + // Keep updating the static icons in the most leaf node + + if (icon) { + leafSegmentStaticIcons.icon = icon + } + if (apple) { + leafSegmentStaticIcons.apple = apple + } + + // file based metadata is specified and current level metadata twitter.images is not specified + if (twitter && !source?.twitter?.hasOwnProperty('images')) { + const resolvedTwitter = resolveTwitter( + { ...target.twitter, images: twitter } as Twitter, + metadataBase, + { ...metadataContext, isStaticMetadataRouteFile: true }, + titleTemplates.twitter + ) + target.twitter = convertUrlsToStrings(resolvedTwitter) + } + + // file based metadata is specified and current level metadata openGraph.images is not specified + if (openGraph && !source?.openGraph?.hasOwnProperty('images')) { + const resolvedOpenGraph = await resolveOpenGraph( + { ...target.openGraph, images: openGraph } as OpenGraph, + metadataBase, + pathname, + { ...metadataContext, isStaticMetadataRouteFile: true }, + titleTemplates.openGraph + ) + target.openGraph = convertUrlsToStrings(resolvedOpenGraph) + } + if (manifest) { + target.manifest = manifest + } + + return target +} + +/** + * Merges the given metadata with the resolved metadata. Returns a new object. + */ +export async function mergeMetadata( + route: string, + pathname: Promise, + { + metadata, + resolvedMetadata, + staticFilesMetadata, + titleTemplates, + metadataContext, + buildState, + leafSegmentStaticIcons, + }: { + metadata: Metadata | null + resolvedMetadata: ResolvedMetadata + staticFilesMetadata: StaticMetadata + titleTemplates: TitleTemplates + metadataContext: MetadataContext + buildState: BuildState + leafSegmentStaticIcons: StaticIcons + } +): Promise { + const newResolvedMetadata = structuredClone(resolvedMetadata) + + const metadataBase = normalizeMetadataBase( + metadata?.metadataBase !== undefined + ? metadata.metadataBase + : resolvedMetadata.metadataBase + ) + + for (const key_ in metadata) { + const key = key_ as keyof Metadata + + switch (key) { + case 'title': { + newResolvedMetadata.title = resolveTitle( + metadata.title, + titleTemplates.title + ) + break + } + case 'alternates': { + newResolvedMetadata.alternates = convertUrlsToStrings( + await resolveAlternates( + metadata.alternates, + metadataBase, + pathname, + metadataContext + ) + ) + break + } + case 'openGraph': { + newResolvedMetadata.openGraph = convertUrlsToStrings( + await resolveOpenGraph( + metadata.openGraph, + metadataBase, + pathname, + metadataContext, + titleTemplates.openGraph + ) + ) + break + } + case 'twitter': { + newResolvedMetadata.twitter = convertUrlsToStrings( + resolveTwitter( + metadata.twitter, + metadataBase, + metadataContext, + titleTemplates.twitter + ) + ) + break + } + case 'facebook': + newResolvedMetadata.facebook = resolveFacebook(metadata.facebook) + break + case 'verification': + newResolvedMetadata.verification = resolveVerification( + metadata.verification + ) + break + + case 'icons': { + newResolvedMetadata.icons = convertUrlsToStrings( + resolveIcons(metadata.icons) + ) + break + } + case 'appleWebApp': + newResolvedMetadata.appleWebApp = resolveAppleWebApp( + metadata.appleWebApp + ) + break + case 'appLinks': + newResolvedMetadata.appLinks = convertUrlsToStrings( + resolveAppLinks(metadata.appLinks) + ) + break + case 'robots': { + newResolvedMetadata.robots = resolveRobots(metadata.robots) + break + } + case 'archives': + case 'assets': + case 'bookmarks': + case 'keywords': { + newResolvedMetadata[key] = resolveAsArrayOrUndefined(metadata[key]) + break + } + case 'authors': { + newResolvedMetadata[key] = convertUrlsToStrings( + resolveAsArrayOrUndefined(metadata.authors) + ) + break + } + case 'itunes': { + newResolvedMetadata[key] = await resolveItunes( + metadata.itunes, + metadataBase, + pathname, + metadataContext + ) + break + } + case 'pagination': { + newResolvedMetadata.pagination = await resolvePagination( + metadata.pagination, + metadataBase, + pathname, + metadataContext + ) + break + } + // directly assign fields that fallback to null + case 'abstract': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'applicationName': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'description': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'generator': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'creator': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'publisher': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'category': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'classification': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'referrer': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'formatDetection': + newResolvedMetadata[key] = metadata[key] ?? null + break + case 'manifest': + newResolvedMetadata[key] = convertUrlsToStrings(metadata[key]) ?? null + break + case 'pinterest': + newResolvedMetadata[key] = convertUrlsToStrings(metadata[key]) ?? null + break + case 'other': + newResolvedMetadata.other = Object.assign( + {}, + newResolvedMetadata.other, + metadata.other + ) + if (metadata.other) { + if ('apple-touch-fullscreen' in metadata.other) { + buildState.warnings.add( + `Use appleWebApp instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` + ) + } + if ('apple-touch-icon-precomposed' in metadata.other) { + buildState.warnings.add( + `Use icons.apple instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` + ) + } + } + break + case 'metadataBase': + newResolvedMetadata.metadataBase = metadataBase + ? metadataBase.toString() + : null + break + + case 'apple-touch-fullscreen': { + buildState.warnings.add( + `Use appleWebApp instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` + ) + break + } + case 'apple-touch-icon-precomposed': { + buildState.warnings.add( + `Use icons.apple instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` + ) + break + } + case 'themeColor': + case 'colorScheme': + case 'viewport': + if (metadata[key] != null) { + buildState.warnings.add( + `Unsupported metadata ${key} is configured in metadata export in ${route}. Please move it to viewport export instead.\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport` + ) + } + break + default: { + key satisfies never + } + } + } + + return mergeStaticMetadata( + metadataBase, + metadata, + newResolvedMetadata, + staticFilesMetadata, + metadataContext, + titleTemplates, + leafSegmentStaticIcons, + pathname + ) +} + +/** + * Merges the given viewport with the resolved viewport. Returns a new object. + */ +export function mergeViewport({ + resolvedViewport, + viewport, +}: { + resolvedViewport: ResolvedViewport + viewport: Viewport | null +}): ResolvedViewport { + const newResolvedViewport = structuredClone(resolvedViewport) + + if (viewport) { + for (const key_ in viewport) { + const key = key_ as keyof Viewport + + switch (key) { + case 'themeColor': { + newResolvedViewport.themeColor = resolveThemeColor( + viewport.themeColor + ) + break + } + case 'colorScheme': + newResolvedViewport.colorScheme = viewport.colorScheme || null + break + case 'width': + case 'height': + case 'initialScale': + case 'minimumScale': + case 'maximumScale': + case 'userScalable': + case 'viewportFit': + case 'interactiveWidget': + // always override the target with the source + // @ts-ignore viewport properties + newResolvedViewport[key] = viewport[key] + break + default: + key satisfies never + } + } + } + + return newResolvedViewport +} + +export function getDefinedViewport( + mod: any, + props: SegmentProps, + tracingProps: { route: string } +): Viewport | ViewportResolver | null { + if (typeof mod.generateViewport === 'function') { + const { route } = tracingProps + const segmentProps = createSegmentProps(mod.generateViewport, props) + + return Object.assign( + (parent: ResolvingViewport) => + getTracer().trace( + ResolveMetadataSpan.generateViewport, + { + spanName: `generateViewport ${route}`, + attributes: { + 'next.page': route, + }, + }, + () => mod.generateViewport(segmentProps, parent) + ), + { $$original: mod.generateViewport } + ) + } + return mod.viewport || null +} + +export function getDefinedMetadata( + mod: any, + props: SegmentProps, + tracingProps: { route: string } +): Metadata | MetadataResolver | null { + if (typeof mod.generateMetadata === 'function') { + const { route } = tracingProps + const segmentProps = createSegmentProps(mod.generateMetadata, props) + + return Object.assign( + (parent: ResolvingMetadata) => + getTracer().trace( + ResolveMetadataSpan.generateMetadata, + { + spanName: `generateMetadata ${route}`, + attributes: { + 'next.page': route, + }, + }, + () => mod.generateMetadata(segmentProps, parent) + ), + { $$original: mod.generateMetadata } + ) + } + return mod.metadata || null +} + +/** + * If `fn` is a `'use cache'` function, we add special markers to the props, + * that the cache wrapper reads and removes, before passing the props to the + * user function. + */ +function createSegmentProps( + fn: Function, + props: SegmentProps +): SegmentProps | UseCacheSegmentProps { + return isUseCacheFunction(fn) + ? 'searchParams' in props + ? { ...props, $$isPage: true } + : { ...props, $$isLayout: true } + : props +} + +async function collectStaticImagesFiles( + metadata: AppDirModules['metadata'], + props: SegmentProps, + type: keyof NonNullable +) { + if (!metadata?.[type]) return undefined + + const iconPromises = metadata[type as 'icon' | 'apple'].map( + async (imageModule: (p: any) => Promise) => + await imageModule(props) + ) + + return iconPromises?.length > 0 + ? (await Promise.all(iconPromises)).flat() + : undefined +} + +export async function resolveStaticMetadata( + modules: AppDirModules, + props: SegmentProps +): Promise { + const { metadata } = modules + if (!metadata) return null + + const [icon, apple, openGraph, twitter] = await Promise.all([ + collectStaticImagesFiles(metadata, props, 'icon'), + collectStaticImagesFiles(metadata, props, 'apple'), + collectStaticImagesFiles(metadata, props, 'openGraph'), + collectStaticImagesFiles(metadata, props, 'twitter'), + ]) + + const staticMetadata = { + icon, + apple, + openGraph, + twitter, + manifest: metadata.manifest, + } + + return staticMetadata +} + +type WithTitle = { title?: AbsoluteTemplateString | null } +type WithDescription = { description?: string | null } + +const isTitleTruthy = (title: AbsoluteTemplateString | null | undefined) => + !!title?.absolute +const hasTitle = (metadata: WithTitle | null) => isTitleTruthy(metadata?.title) + +function inheritFromMetadata( + target: (WithTitle & WithDescription) | null, + metadata: ResolvedMetadata +) { + if (target) { + if (!hasTitle(target) && hasTitle(metadata)) { + target.title = metadata.title + } + if (!target.description && metadata.description) { + target.description = metadata.description + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const commonOgKeys = ['title', 'description', 'images'] as const +export function postProcessMetadata( + metadata: ResolvedMetadata, + favicon: any, + titleTemplates: TitleTemplates, + metadataContext: MetadataContext +): ResolvedMetadata { + const { openGraph, twitter } = metadata + + if (openGraph) { + // If there's openGraph information but not configured in twitter, + // inherit them from openGraph metadata. + let autoFillProps: Partial<{ + [Key in (typeof commonOgKeys)[number]]: NonNullable< + ResolvedMetadata['openGraph'] + >[Key] + }> = {} + const hasTwTitle = hasTitle(twitter) + const hasTwDescription = twitter?.description + const hasTwImages = Boolean( + twitter?.hasOwnProperty('images') && twitter.images + ) + if (!hasTwTitle) { + if (isTitleTruthy(openGraph.title)) { + autoFillProps.title = openGraph.title + } else if (metadata.title && isTitleTruthy(metadata.title)) { + autoFillProps.title = metadata.title + } + } + if (!hasTwDescription) + autoFillProps.description = + openGraph.description || metadata.description || undefined + if (!hasTwImages) autoFillProps.images = openGraph.images + + if (Object.keys(autoFillProps).length > 0) { + const partialTwitter = resolveTwitter( + autoFillProps, + normalizeMetadataBase(metadata.metadataBase), + metadataContext, + titleTemplates.twitter + ) + if (metadata.twitter) { + metadata.twitter = Object.assign({}, metadata.twitter, { + ...(!hasTwTitle && { title: partialTwitter?.title }), + ...(!hasTwDescription && { + description: partialTwitter?.description, + }), + ...(!hasTwImages && { images: partialTwitter?.images }), + }) + } else { + metadata.twitter = convertUrlsToStrings(partialTwitter) + } + } + } + + // If there's no title and description configured in openGraph or twitter, + // use the title and description from metadata. + inheritFromMetadata(openGraph, metadata) + inheritFromMetadata(twitter, metadata) + + if (favicon) { + if (!metadata.icons) { + metadata.icons = { + icon: [], + apple: [], + } + } + + metadata.icons.icon.unshift(favicon) + } + + return metadata +} + +export function createSelectedMetadata( + metadata: ResolvedMetadata +): SelectedMetadata { + const { + metadataBase, + title, + openGraph, + twitter, + themeColor, + colorScheme, + viewport, + ...metadataTagFields + } = metadata + + // These fields are only used while resolving metadata, or are rendered by + // the separate viewport pipeline. + void metadataBase + void themeColor + void colorScheme + void viewport + + return { + ...metadataTagFields, + title: title?.absolute || null, + openGraph: openGraph + ? { ...openGraph, title: openGraph.title.absolute } + : null, + twitter: twitter ? { ...twitter, title: twitter.title.absolute } : null, + } +} diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 6e8ba28e2026..92cddd556ae9 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -2,29 +2,15 @@ import type { Metadata, ResolvedMetadata, ResolvedViewport, - ResolvingMetadata, - ResolvingViewport, Viewport, - WithStringifiedURLs, } from './types/metadata-interface' -import type { MetadataImageModule } from '../../build/webpack/loaders/metadata/types' import { getSegmentParam } from '../../shared/lib/router/utils/get-segment-param' -import type { Twitter } from './types/twitter-types' -import type { OpenGraph } from './types/opengraph-types' -import type { AppDirModules } from '../../build/webpack/loaders/next-app-loader' import type { MetadataContext } from './types/resolvers' import type { LoaderTree } from '../../server/lib/app-dir-module' -import type { - AbsoluteTemplateString, - IconDescriptor, - ResolvedIcons, -} from './types/metadata-types' import type { ParsedUrlQuery } from 'querystring' -import type { StaticMetadata } from './types/icons' import { workAsyncStorage } from '../../server/app-render/work-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' import type { Params } from '../../server/request/params' -import type { SearchParams } from '../../server/request/search-params' // eslint-disable-next-line import/no-extraneous-dependencies import 'server-only' @@ -34,601 +20,43 @@ import { createDefaultMetadata, createDefaultViewport, } from './default-metadata' -import { resolveOpenGraph, resolveTwitter } from './resolvers/resolve-opengraph' -import { resolveTitle } from './resolvers/resolve-title' -import { resolveAsArrayOrUndefined } from './generate/utils' import { getComponentTypeModule, getLayoutOrPageModule, } from '../../server/lib/app-dir-module' -import { - resolveAlternates, - resolveAppleWebApp, - resolveAppLinks, - resolveRobots, - resolveThemeColor, - resolveVerification, - resolveItunes, - resolveFacebook, - resolvePagination, -} from './resolvers/resolve-basics' -import { resolveIcons } from './resolvers/resolve-icons' -import { getTracer } from '../../server/lib/trace/tracer' -import { ResolveMetadataSpan } from '../../server/lib/trace/constants' import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment' import * as Log from '../../build/output/log' import { createServerParamsForMetadata } from '../../server/request/params' -import type { MetadataBaseURL } from './resolvers/resolve-url' -import { - getUseCacheFunctionInfo, - isUseCacheFunction, -} from '../client-and-server-references' -import type { - UseCacheLayoutProps, - UseCachePageProps, -} from '../../server/use-cache/use-cache-wrapper' +import { getUseCacheFunctionInfo } from '../client-and-server-references' import { createLazyResult } from '../../server/lib/lazy-result' - -type StaticIcons = Pick - -type Resolved = T extends Metadata ? ResolvedMetadata : ResolvedViewport - -type InstrumentedResolver = (( - parent: Promise> -) => TData | Promise) & { - $$original: ( - props: unknown, - parent: Promise> - ) => TData | Promise -} - -type MetadataResolver = InstrumentedResolver -type ViewportResolver = InstrumentedResolver - -export type MetadataErrorType = 'not-found' | 'forbidden' | 'unauthorized' - -export type MetadataItems = Array< - [Metadata | MetadataResolver | null, StaticMetadata] -> - -export type ViewportItems = Array - -type WithSelectedTitle = T extends { title: AbsoluteTemplateString } - ? Omit & { title: string } - : T - -/** - * Metadata that has finished route-level resolution and post-processing. It - * contains only values that can be turned into metadata elements; it is never - * used as the parent of another metadata resolver. - */ -export type SelectedMetadata = Omit< - ResolvedMetadata, - | 'metadataBase' - | 'title' - | 'openGraph' - | 'twitter' - | 'themeColor' - | 'colorScheme' - | 'viewport' -> & { - title: string | null - openGraph: WithSelectedTitle< - NonNullable - > | null - twitter: WithSelectedTitle> | null -} - -type TitleTemplates = { - title: string | null - twitter: string | null - openGraph: string | null -} - -type BuildState = { - warnings: Set -} - -type LayoutProps = { - params: Promise -} - -type PageProps = { - params: Promise - searchParams: Promise -} - -type SegmentProps = LayoutProps | PageProps -type UseCacheSegmentProps = UseCacheLayoutProps | UseCachePageProps - -function isFavicon(icon: IconDescriptor | undefined): boolean { - if (!icon) { - return false - } - - // turbopack appends a hash to all images - return ( - (icon.url === '/favicon.ico' || - icon.url.toString().startsWith('/favicon.ico?')) && - icon.type === 'image/x-icon' - ) -} - -function convertUrlsToStrings(input: T): WithStringifiedURLs { - if (input instanceof URL) { - return input.toString() as unknown as WithStringifiedURLs - } else if (Array.isArray(input)) { - return input.map((item) => - convertUrlsToStrings(item) - ) as WithStringifiedURLs - } else if (input && typeof input === 'object') { - const result: Record = {} - for (const [key, value] of Object.entries(input)) { - result[key] = convertUrlsToStrings(value) - } - return result as WithStringifiedURLs - } - return input as WithStringifiedURLs -} - -function normalizeMetadataBase(metadataBase: string | URL | null): URL | null { - if (typeof metadataBase === 'string') { - try { - metadataBase = new URL(metadataBase) - } catch { - throw new Error(`metadataBase is not a valid URL: ${metadataBase}`) - } - } - return metadataBase -} - -async function mergeStaticMetadata( - metadataBase: MetadataBaseURL, - source: Metadata | null, - target: ResolvedMetadata, - staticFilesMetadata: StaticMetadata, - metadataContext: MetadataContext, - titleTemplates: TitleTemplates, - leafSegmentStaticIcons: StaticIcons, - pathname: Promise -): Promise { - if (!staticFilesMetadata) return target - const { icon, apple, openGraph, twitter, manifest } = staticFilesMetadata - - // Keep updating the static icons in the most leaf node - - if (icon) { - leafSegmentStaticIcons.icon = icon - } - if (apple) { - leafSegmentStaticIcons.apple = apple - } - - // file based metadata is specified and current level metadata twitter.images is not specified - if (twitter && !source?.twitter?.hasOwnProperty('images')) { - const resolvedTwitter = resolveTwitter( - { ...target.twitter, images: twitter } as Twitter, - metadataBase, - { ...metadataContext, isStaticMetadataRouteFile: true }, - titleTemplates.twitter - ) - target.twitter = convertUrlsToStrings(resolvedTwitter) - } - - // file based metadata is specified and current level metadata openGraph.images is not specified - if (openGraph && !source?.openGraph?.hasOwnProperty('images')) { - const resolvedOpenGraph = await resolveOpenGraph( - { ...target.openGraph, images: openGraph } as OpenGraph, - metadataBase, - pathname, - { ...metadataContext, isStaticMetadataRouteFile: true }, - titleTemplates.openGraph - ) - target.openGraph = convertUrlsToStrings(resolvedOpenGraph) - } - if (manifest) { - target.manifest = manifest - } - - return target -} - -/** - * Merges the given metadata with the resolved metadata. Returns a new object. - */ -async function mergeMetadata( - route: string, - pathname: Promise, - { - metadata, - resolvedMetadata, - staticFilesMetadata, - titleTemplates, - metadataContext, - buildState, - leafSegmentStaticIcons, - }: { - metadata: Metadata | null - resolvedMetadata: ResolvedMetadata - staticFilesMetadata: StaticMetadata - titleTemplates: TitleTemplates - metadataContext: MetadataContext - buildState: BuildState - leafSegmentStaticIcons: StaticIcons - } -): Promise { - const newResolvedMetadata = structuredClone(resolvedMetadata) - - const metadataBase = normalizeMetadataBase( - metadata?.metadataBase !== undefined - ? metadata.metadataBase - : resolvedMetadata.metadataBase - ) - - for (const key_ in metadata) { - const key = key_ as keyof Metadata - - switch (key) { - case 'title': { - newResolvedMetadata.title = resolveTitle( - metadata.title, - titleTemplates.title - ) - break - } - case 'alternates': { - newResolvedMetadata.alternates = convertUrlsToStrings( - await resolveAlternates( - metadata.alternates, - metadataBase, - pathname, - metadataContext - ) - ) - break - } - case 'openGraph': { - newResolvedMetadata.openGraph = convertUrlsToStrings( - await resolveOpenGraph( - metadata.openGraph, - metadataBase, - pathname, - metadataContext, - titleTemplates.openGraph - ) - ) - break - } - case 'twitter': { - newResolvedMetadata.twitter = convertUrlsToStrings( - resolveTwitter( - metadata.twitter, - metadataBase, - metadataContext, - titleTemplates.twitter - ) - ) - break - } - case 'facebook': - newResolvedMetadata.facebook = resolveFacebook(metadata.facebook) - break - case 'verification': - newResolvedMetadata.verification = resolveVerification( - metadata.verification - ) - break - - case 'icons': { - newResolvedMetadata.icons = convertUrlsToStrings( - resolveIcons(metadata.icons) - ) - break - } - case 'appleWebApp': - newResolvedMetadata.appleWebApp = resolveAppleWebApp( - metadata.appleWebApp - ) - break - case 'appLinks': - newResolvedMetadata.appLinks = convertUrlsToStrings( - resolveAppLinks(metadata.appLinks) - ) - break - case 'robots': { - newResolvedMetadata.robots = resolveRobots(metadata.robots) - break - } - case 'archives': - case 'assets': - case 'bookmarks': - case 'keywords': { - newResolvedMetadata[key] = resolveAsArrayOrUndefined(metadata[key]) - break - } - case 'authors': { - newResolvedMetadata[key] = convertUrlsToStrings( - resolveAsArrayOrUndefined(metadata.authors) - ) - break - } - case 'itunes': { - newResolvedMetadata[key] = await resolveItunes( - metadata.itunes, - metadataBase, - pathname, - metadataContext - ) - break - } - case 'pagination': { - newResolvedMetadata.pagination = await resolvePagination( - metadata.pagination, - metadataBase, - pathname, - metadataContext - ) - break - } - // directly assign fields that fallback to null - case 'abstract': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'applicationName': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'description': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'generator': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'creator': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'publisher': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'category': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'classification': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'referrer': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'formatDetection': - newResolvedMetadata[key] = metadata[key] ?? null - break - case 'manifest': - newResolvedMetadata[key] = convertUrlsToStrings(metadata[key]) ?? null - break - case 'pinterest': - newResolvedMetadata[key] = convertUrlsToStrings(metadata[key]) ?? null - break - case 'other': - newResolvedMetadata.other = Object.assign( - {}, - newResolvedMetadata.other, - metadata.other - ) - if (metadata.other) { - if ('apple-touch-fullscreen' in metadata.other) { - buildState.warnings.add( - `Use appleWebApp instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` - ) - } - if ('apple-touch-icon-precomposed' in metadata.other) { - buildState.warnings.add( - `Use icons.apple instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` - ) - } - } - break - case 'metadataBase': - newResolvedMetadata.metadataBase = metadataBase - ? metadataBase.toString() - : null - break - - case 'apple-touch-fullscreen': { - buildState.warnings.add( - `Use appleWebApp instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` - ) - break - } - case 'apple-touch-icon-precomposed': { - buildState.warnings.add( - `Use icons.apple instead\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata` - ) - break - } - case 'themeColor': - case 'colorScheme': - case 'viewport': - if (metadata[key] != null) { - buildState.warnings.add( - `Unsupported metadata ${key} is configured in metadata export in ${route}. Please move it to viewport export instead.\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport` - ) - } - break - default: { - key satisfies never - } - } - } - - return mergeStaticMetadata( - metadataBase, - metadata, - newResolvedMetadata, - staticFilesMetadata, - metadataContext, - titleTemplates, - leafSegmentStaticIcons, - pathname - ) -} - -/** - * Merges the given viewport with the resolved viewport. Returns a new object. - */ -function mergeViewport({ - resolvedViewport, - viewport, -}: { - resolvedViewport: ResolvedViewport - viewport: Viewport | null -}): ResolvedViewport { - const newResolvedViewport = structuredClone(resolvedViewport) - - if (viewport) { - for (const key_ in viewport) { - const key = key_ as keyof Viewport - - switch (key) { - case 'themeColor': { - newResolvedViewport.themeColor = resolveThemeColor( - viewport.themeColor - ) - break - } - case 'colorScheme': - newResolvedViewport.colorScheme = viewport.colorScheme || null - break - case 'width': - case 'height': - case 'initialScale': - case 'minimumScale': - case 'maximumScale': - case 'userScalable': - case 'viewportFit': - case 'interactiveWidget': - // always override the target with the source - // @ts-ignore viewport properties - newResolvedViewport[key] = viewport[key] - break - default: - key satisfies never - } - } - } - - return newResolvedViewport -} - -function getDefinedViewport( - mod: any, - props: SegmentProps, - tracingProps: { route: string } -): Viewport | ViewportResolver | null { - if (typeof mod.generateViewport === 'function') { - const { route } = tracingProps - const segmentProps = createSegmentProps(mod.generateViewport, props) - - return Object.assign( - (parent: ResolvingViewport) => - getTracer().trace( - ResolveMetadataSpan.generateViewport, - { - spanName: `generateViewport ${route}`, - attributes: { - 'next.page': route, - }, - }, - () => mod.generateViewport(segmentProps, parent) - ), - { $$original: mod.generateViewport } - ) - } - return mod.viewport || null -} - -function getDefinedMetadata( - mod: any, - props: SegmentProps, - tracingProps: { route: string } -): Metadata | MetadataResolver | null { - if (typeof mod.generateMetadata === 'function') { - const { route } = tracingProps - const segmentProps = createSegmentProps(mod.generateMetadata, props) - - return Object.assign( - (parent: ResolvingMetadata) => - getTracer().trace( - ResolveMetadataSpan.generateMetadata, - { - spanName: `generateMetadata ${route}`, - attributes: { - 'next.page': route, - }, - }, - () => mod.generateMetadata(segmentProps, parent) - ), - { $$original: mod.generateMetadata } - ) - } - return mod.metadata || null -} - -/** - * If `fn` is a `'use cache'` function, we add special markers to the props, - * that the cache wrapper reads and removes, before passing the props to the - * user function. - */ -function createSegmentProps( - fn: Function, - props: SegmentProps -): SegmentProps | UseCacheSegmentProps { - return isUseCacheFunction(fn) - ? 'searchParams' in props - ? { ...props, $$isPage: true } - : { ...props, $$isLayout: true } - : props -} - -async function collectStaticImagesFiles( - metadata: AppDirModules['metadata'], - props: SegmentProps, - type: keyof NonNullable -) { - if (!metadata?.[type]) return undefined - - const iconPromises = metadata[type as 'icon' | 'apple'].map( - async (imageModule: (p: any) => Promise) => - await imageModule(props) - ) - - return iconPromises?.length > 0 - ? (await Promise.all(iconPromises)).flat() - : undefined -} - -async function resolveStaticMetadata( - modules: AppDirModules, - props: SegmentProps -): Promise { - const { metadata } = modules - if (!metadata) return null - - const [icon, apple, openGraph, twitter] = await Promise.all([ - collectStaticImagesFiles(metadata, props, 'icon'), - collectStaticImagesFiles(metadata, props, 'apple'), - collectStaticImagesFiles(metadata, props, 'openGraph'), - collectStaticImagesFiles(metadata, props, 'twitter'), - ]) - - const staticMetadata = { - icon, - apple, - openGraph, - twitter, - manifest: metadata.manifest, - } - - return staticMetadata -} +import { + type InstrumentedResolver, + type LayoutProps, + type MetadataErrorType, + type MetadataItems, + type PageProps, + type Resolved, + type SegmentProps, + type SelectedMetadata, + type TitleTemplates, + type ViewportItems, + createSelectedMetadata, + getDefinedMetadata, + getDefinedViewport, + isFavicon, + mergeMetadata, + mergeViewport, + postProcessMetadata, + resolveStaticMetadata, +} from './metadata-resolution-primitives' + +export type { + MetadataErrorType, + MetadataItems, + SelectedMetadata, + ViewportItems, +} +export { createSelectedMetadata } // [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata] async function collectMetadata({ @@ -680,7 +108,6 @@ async function collectMetadata({ errorMetadataItem[1] = staticFilesMetadata } } - // [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata] async function collectViewport({ tree, @@ -952,133 +379,6 @@ async function resolveViewportItemsImpl( return viewportItems } -type WithTitle = { title?: AbsoluteTemplateString | null } -type WithDescription = { description?: string | null } - -const isTitleTruthy = (title: AbsoluteTemplateString | null | undefined) => - !!title?.absolute -const hasTitle = (metadata: WithTitle | null) => isTitleTruthy(metadata?.title) - -function inheritFromMetadata( - target: (WithTitle & WithDescription) | null, - metadata: ResolvedMetadata -) { - if (target) { - if (!hasTitle(target) && hasTitle(metadata)) { - target.title = metadata.title - } - if (!target.description && metadata.description) { - target.description = metadata.description - } - } -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const commonOgKeys = ['title', 'description', 'images'] as const -function postProcessMetadata( - metadata: ResolvedMetadata, - favicon: any, - titleTemplates: TitleTemplates, - metadataContext: MetadataContext -): ResolvedMetadata { - const { openGraph, twitter } = metadata - - if (openGraph) { - // If there's openGraph information but not configured in twitter, - // inherit them from openGraph metadata. - let autoFillProps: Partial<{ - [Key in (typeof commonOgKeys)[number]]: NonNullable< - ResolvedMetadata['openGraph'] - >[Key] - }> = {} - const hasTwTitle = hasTitle(twitter) - const hasTwDescription = twitter?.description - const hasTwImages = Boolean( - twitter?.hasOwnProperty('images') && twitter.images - ) - if (!hasTwTitle) { - if (isTitleTruthy(openGraph.title)) { - autoFillProps.title = openGraph.title - } else if (metadata.title && isTitleTruthy(metadata.title)) { - autoFillProps.title = metadata.title - } - } - if (!hasTwDescription) - autoFillProps.description = - openGraph.description || metadata.description || undefined - if (!hasTwImages) autoFillProps.images = openGraph.images - - if (Object.keys(autoFillProps).length > 0) { - const partialTwitter = resolveTwitter( - autoFillProps, - normalizeMetadataBase(metadata.metadataBase), - metadataContext, - titleTemplates.twitter - ) - if (metadata.twitter) { - metadata.twitter = Object.assign({}, metadata.twitter, { - ...(!hasTwTitle && { title: partialTwitter?.title }), - ...(!hasTwDescription && { - description: partialTwitter?.description, - }), - ...(!hasTwImages && { images: partialTwitter?.images }), - }) - } else { - metadata.twitter = convertUrlsToStrings(partialTwitter) - } - } - } - - // If there's no title and description configured in openGraph or twitter, - // use the title and description from metadata. - inheritFromMetadata(openGraph, metadata) - inheritFromMetadata(twitter, metadata) - - if (favicon) { - if (!metadata.icons) { - metadata.icons = { - icon: [], - apple: [], - } - } - - metadata.icons.icon.unshift(favicon) - } - - return metadata -} - -export function createSelectedMetadata( - metadata: ResolvedMetadata -): SelectedMetadata { - const { - metadataBase, - title, - openGraph, - twitter, - themeColor, - colorScheme, - viewport, - ...metadataTagFields - } = metadata - - // These fields are only used while resolving metadata, or are rendered by - // the separate viewport pipeline. - void metadataBase - void themeColor - void colorScheme - void viewport - - return { - ...metadataTagFields, - title: title?.absolute || null, - openGraph: openGraph - ? { ...openGraph, title: openGraph.title.absolute } - : null, - twitter: twitter ? { ...twitter, title: twitter.title.absolute } : null, - } -} - type Result = null | T | Promise | PromiseLike function prerenderMetadata(metadataItems: MetadataItems) { diff --git a/test/e2e/app-dir/back-before-hydration/app/search/page.tsx b/test/e2e/app-dir/back-before-hydration/app/search/page.tsx index bb71d4df5c2c..1f4bed7d0706 100644 --- a/test/e2e/app-dir/back-before-hydration/app/search/page.tsx +++ b/test/e2e/app-dir/back-before-hydration/app/search/page.tsx @@ -7,7 +7,7 @@ async function CurrentPage({ searchParams: Promise<{ page?: string }> }) { const { page = '1' } = await searchParams - return

Page {page}

+ return

Page {page}

} export default function Search({ diff --git a/test/e2e/app-dir/back-before-hydration/app/suspense/search/page.tsx b/test/e2e/app-dir/back-before-hydration/app/suspense/search/page.tsx index 94a58b0ce5b4..2e1cfc69367a 100644 --- a/test/e2e/app-dir/back-before-hydration/app/suspense/search/page.tsx +++ b/test/e2e/app-dir/back-before-hydration/app/suspense/search/page.tsx @@ -7,7 +7,7 @@ async function CurrentPage({ searchParams: Promise<{ page?: string }> }) { const { page = '1' } = await searchParams - return

Page {page}

+ return

Page {page}

} export default function Search({ diff --git a/test/e2e/app-dir/back-before-hydration/back-before-hydration.test.ts b/test/e2e/app-dir/back-before-hydration/back-before-hydration.test.ts index ab9c8d3c50cc..51b30e1b53d9 100644 --- a/test/e2e/app-dir/back-before-hydration/back-before-hydration.test.ts +++ b/test/e2e/app-dir/back-before-hydration/back-before-hydration.test.ts @@ -49,6 +49,20 @@ describe('back navigation before hydration after reload', () => { } } + // Waits for the heading of the page we expect to end up on; every page has + // its own heading id. `waitUntil` is off because the load event doesn't fire + // until the released scripts have run, which is most of what we're waiting + // for. + function waitForPage( + browser: Awaited>, + headingSelector: string + ) { + return browser.elementByCss(headingSelector, { + waitUntil: false, + timeout: 10_000, + }) + } + // Navigates client-side (creating a same-document sibling entry), then // reloads with scripts stalled, returning as soon as the new document // commits. `window.__stayed` is set on the committed document so tests can @@ -60,7 +74,7 @@ describe('back navigation before hydration after reload', () => { async function clickThenReloadStalled( startPath: string, linkId: string, - headingAfterClick: string + headingSelectorAfterClick: string ) { let page: Playwright.Page const browser = await next.browser(startPath, { @@ -70,9 +84,7 @@ describe('back navigation before hydration after reload', () => { }) await browser.elementById(linkId).click() - await retry(async () => { - expect(await browser.elementByCss('h1').text()).toBe(headingAfterClick) - }) + await waitForPage(browser, headingSelectorAfterClick) const releaseScripts = await stallScripts(page) await browser.refresh({ waitUntil: 'commit' }) @@ -112,7 +124,7 @@ describe('back navigation before hydration after reload', () => { const { browser, releaseScripts } = await clickThenReloadStalled( homePath, 'to-post', - 'Post' + '#post' ) // Back while the reloaded document is not hydrated: an instant @@ -124,30 +136,24 @@ describe('back navigation before hydration after reload', () => { // We traversed back, so once the router is up it must render the home // page (or otherwise bring URL and content back in sync). - await retry(async () => { - expect(new URL(await browser.url()).pathname).toBe(homePath) - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') + expect(new URL(await browser.url()).pathname).toBe(homePath) // History traversal must still work after recovery. await browser.forward() - await retry(async () => { - expect(new URL(await browser.url()).pathname).toBe(postPath) - expect(await browser.elementByCss('h1').text()).toBe('Post') - }) + await waitForPage(browser, '#post') + expect(new URL(await browser.url()).pathname).toBe(postPath) await browser.back() - await retry(async () => { - expect(new URL(await browser.url()).pathname).toBe(homePath) - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') + expect(new URL(await browser.url()).pathname).toBe(homePath) }) it('reconciles when the traversed entry differs only in search params', async () => { const { browser, releaseScripts } = await clickThenReloadStalled( `${searchPath}?page=1`, 'to-page-2', - 'Page 2' + '#page-2' ) await browser.back({ waitUntil: 'commit' }) @@ -155,16 +161,12 @@ describe('back navigation before hydration after reload', () => { releaseScripts() - await retry(async () => { - expect(new URL(await browser.url()).search).toBe('?page=1') - expect(await browser.elementByCss('h1').text()).toBe('Page 1') - }) + await waitForPage(browser, '#page-1') + expect(new URL(await browser.url()).search).toBe('?page=1') await browser.forward() - await retry(async () => { - expect(new URL(await browser.url()).search).toBe('?page=2') - expect(await browser.elementByCss('h1').text()).toBe('Page 2') - }) + await waitForPage(browser, '#page-2') + expect(new URL(await browser.url()).search).toBe('?page=2') }) // History changes before hydration that are NOT missed traversals must @@ -191,9 +193,7 @@ describe('back navigation before hydration after reload', () => { // The router still navigates. await browser.elementById('to-home').click() - await retry(async () => { - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') }) it('keeps an in-page anchor jump on a fresh load', async () => { @@ -229,7 +229,7 @@ describe('back navigation before hydration after reload', () => { const { browser, page, releaseScripts } = await clickThenReloadStalled( homePath, 'to-post', - 'Post' + '#post' ) await page.click('#hash-link') @@ -245,10 +245,8 @@ describe('back navigation before hydration after reload', () => { // Traversing over the hash entry and the pushState entry still works. await browser.back() // -> post await browser.back() // -> home - await retry(async () => { - expect(new URL(await browser.url()).pathname).toBe(homePath) - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') + expect(new URL(await browser.url()).pathname).toBe(homePath) }) it('handles a pushState followed by back', async () => { @@ -272,16 +270,14 @@ describe('back navigation before hydration after reload', () => { }) await browser.elementById('to-home').click() - await retry(async () => { - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') }) it('leaves the traversal unhandled when a third-party write lands before the replay', async () => { const { browser, page, releaseScripts } = await clickThenReloadStalled( homePath, 'to-post', - 'Post' + '#post' ) await browser.back({ waitUntil: 'commit' }) @@ -302,9 +298,7 @@ describe('back navigation before hydration after reload', () => { }) await browser.elementById('to-home').click() - await retry(async () => { - expect(await browser.elementByCss('h1').text()).toBe('Home') - }) + await waitForPage(browser, '#home') }) it('handles a traversal onto a third-party entry', async () => { diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 465ebffd2858..19115f2b0277 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -300,6 +300,7 @@ async function readNormalizedNFT(next, name) { "/node_modules/next/dist/lib/metadata/get-metadata-route.js", "/node_modules/next/dist/lib/metadata/is-metadata-route.js", "/node_modules/next/dist/lib/metadata/metadata-context.js", + "/node_modules/next/dist/lib/metadata/metadata-resolution-primitives.js", "/node_modules/next/dist/lib/metadata/metadata.js", "/node_modules/next/dist/lib/metadata/resolve-metadata.js", "/node_modules/next/dist/lib/metadata/resolvers/resolve-basics.js",