From 6d0379d4b8558d1157dee78906f0850432453a21 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Sat, 15 Aug 2026 08:28:40 -0700 Subject: [PATCH 1/2] Extract metadata resolution primitives (#97388) ## Summary The existing metadata resolver combines two responsibilities: walking the loader tree to collect and schedule route exports, and interpreting the metadata or viewport value produced by an individual route layer. Move the route-layer operations into metadata-resolution-primitives.ts. This includes wrapping generateMetadata and generateViewport, loading file-based metadata, merging values with their resolved parents, post-processing metadata, and producing SelectedMetadata for rendering. Keep resolve-metadata.ts responsible for the existing traversal and scheduling flow, importing the extracted operations back into the same call sites and preserving its existing exports. This creates a reusable boundary for other traversal strategies without introducing another resolution path. This is intended to be behavior preserving: traversal order, eager generator invocation, parent promise behavior, warnings, and rendered metadata and viewport tags are unchanged. ## Verification - pnpm build-all --- .../metadata-resolution-primitives.ts | 738 +++++++++++++++++ .../next/src/lib/metadata/resolve-metadata.ts | 758 +----------------- .../next-server-nft/next-server-nft.test.ts | 1 + 3 files changed, 768 insertions(+), 729 deletions(-) create mode 100644 packages/next/src/lib/metadata/metadata-resolution-primitives.ts 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/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", From 2fbeebbaca93e8f478d6b9b97a964ac09ec54faf Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 15 Aug 2026 17:03:50 +0100 Subject: [PATCH 2/2] Wait for back-before-hydration recoveries in the browser (#97321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `back-before-hydration` assertions read the heading with `browser.elementByCss`, which waits for the load event before it returns. Here the load event cannot fire until the stalled scripts are released and finish running, and that is the whole window the test is about. So the first attempt inside `retry` blocks for almost the entire 3s budget, looks at the DOM once, and then `retry` refuses a second look because `waited + interval > duration`. This waits in the browser instead, with the load event out of the picture. Every page already has its own heading id, except the two search pages, which shared one; they get a per-page id too. Then a single `waitForSelector` resolves the moment that page commits, and the URL assertion follows the wait rather than racing alongside it. The assertions that check content *stayed* put keep polling with `retry`, since a change there is the failure. Holding the flight response for 1800ms and throttling the renderer 8x reproduces the CI failure exactly on canary — same `Failed to retry within 3000ms`, same expected "Home" / received "Post" — and passes with this change; the suite is still 16/16 with the response held for 6s. Test-only. Co-authored-by: Claude Fable 5 --- .../back-before-hydration/app/search/page.tsx | 2 +- .../app/suspense/search/page.tsx | 2 +- .../back-before-hydration.test.ts | 76 +++++++++---------- 3 files changed, 37 insertions(+), 43 deletions(-) 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 () => {