From 1809012f2bfcc0887fa5c686dabcef1e9c19eeb3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 16:58:27 +1000 Subject: [PATCH] fix(metadata): apply ancestor templates to title defaults Metadata title defaults currently render without the active ancestor template. That diverges from Next.js when a child layout or page provides title.default under a parent layout title.template. The merge path resolved only the final title after collecting templates, so object defaults skipped the stashed template used for that segment. Resolve each title as it is encountered against the current ancestor template, then stash the current layout template for descendants. Covers the child-layout default regression and updates page-default expectations to match Next.js resolveTitle semantics. --- packages/vinext/src/shims/metadata.tsx | 71 +++++++++++++------------- tests/app-page-head.test.ts | 2 +- tests/features.test.ts | 32 +++++++++--- 3 files changed, 62 insertions(+), 43 deletions(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 49e8e2c08..166e82faf 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -343,6 +343,29 @@ function resolveStringTitle(title: Metadata["title"]): string | undefined { return undefined; } +function applyTitleTemplate(template: string | undefined, title: string): string { + return template ? template.replace(/%s/g, title) : title; +} + +function resolveTitle(title: Metadata["title"], stashedTemplate: string | undefined) { + if (typeof title === "string") { + return applyTitleTemplate(stashedTemplate, title); + } + + if (title && typeof title === "object") { + let resolved = + title.default === undefined ? undefined : applyTitleTemplate(stashedTemplate, title.default); + + if (title.absolute) { + resolved = title.absolute; + } + + return resolved; + } + + return undefined; +} + /** * Post-process merged metadata to cross-fill openGraph and Twitter fields. * @@ -400,7 +423,7 @@ export function postProcessMetadata(merged: Metadata): Metadata { if (!hasTwDescription) { autoFill.description = result.openGraph.description || result.description || undefined; } - if (!hasTwImages) { + if (!hasTwImages && result.openGraph.images !== undefined) { autoFill.images = result.openGraph.images; } @@ -456,7 +479,7 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me const merged: Metadata = {}; - // Track the most recent title template from LAYOUTS (not from page). + // Track the most recent ancestor title template from layouts (not from page). let parentTemplate: string | undefined; for (const entry of entries) { @@ -464,17 +487,6 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me const isPage = Boolean(entry.isPage); const contributesTitle = entry.contributesTitle !== false; - // Collect template from layouts only (page templates are ignored per Next.js spec) - if ( - contributesTitle && - !isPage && - meta.title && - typeof meta.title === "object" && - meta.title.template - ) { - parentTemplate = meta.title.template; - } - // Merge non-title keys for (const key of Object.keys(meta)) { if (key === "title") continue; // Handle title separately below @@ -492,30 +504,19 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me // Title resolution if (contributesTitle && meta.title !== undefined) { - merged.title = meta.title; + merged.title = resolveTitle(meta.title, parentTemplate); } - } - // Now resolve the final title, applying the parent template if applicable - const finalTitle = merged.title; - if (finalTitle) { - if (typeof finalTitle === "string") { - // Simple string title — apply parent template - if (parentTemplate) { - merged.title = parentTemplate.replace("%s", finalTitle); - } - } else if (typeof finalTitle === "object") { - if (finalTitle.absolute) { - // Absolute title — skip all templates - merged.title = finalTitle.absolute; - } else if (finalTitle.default) { - // Title object with default — this is used when the segment IS the - // defining layout (its own default doesn't get template-wrapped) - merged.title = finalTitle.default; - } else if (finalTitle.template && !finalTitle.default && !finalTitle.absolute) { - // Template only with no default — no title to render - merged.title = undefined; - } + // Collect the current layout template after resolving its own title so + // title.default is wrapped by the ancestor template, not by its own template. + if ( + contributesTitle && + !isPage && + meta.title && + typeof meta.title === "object" && + meta.title.template + ) { + parentTemplate = meta.title.template; } } diff --git a/tests/app-page-head.test.ts b/tests/app-page-head.test.ts index 19bed5476..17d66fcc6 100644 --- a/tests/app-page-head.test.ts +++ b/tests/app-page-head.test.ts @@ -383,7 +383,7 @@ describe("app page head resolution", () => { description: "Primary page", title: "Slot OG title", }, - title: "Page", + title: "Page | Root", twitter: { card: "summary", description: "Primary page", diff --git a/tests/features.test.ts b/tests/features.test.ts index 27bb3e799..141fee84f 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -2133,6 +2133,21 @@ describe("metadata title templates", () => { expect(result.title).toBe("My Site"); }); + it("applies ancestor title template to child layout default title", () => { + // Next.js resolveTitle() applies the stashed ancestor template to title.default: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-title.ts + const result = mergeMetadataEntries([ + { + metadata: { title: { template: "%s | Site", default: "Site" } }, + }, + { + metadata: { title: { default: "Blog" } }, + }, + ]); + + expect(result.title).toBe("Blog | Site"); + }); + it("title.absolute skips all templates", () => { const result = mergeMetadata([ { title: { template: "%s | My Site", default: "My Site" } }, @@ -2150,16 +2165,19 @@ describe("metadata title templates", () => { expect(result.title).toBe("Hello World - Blog"); }); - it("page template has no effect (page is terminal)", () => { - // If the page defines a template, it should be ignored - // Only layouts define templates, and page is always the last entry + it("applies ancestor template to page default while ignoring page template", () => { const result = mergeMetadata([ { title: { template: "%s | Site", default: "Site" } }, { title: { template: "%s - Page Template", default: "Page Default" } }, ]); - // The page's template should be ignored; the page's default is used - // because the page has a title object (not a string), so we use its default - expect(result.title).toBe("Page Default"); + + expect(result.title).toBe("Page Default | Site"); + }); + + it("does not apply a page template to the page's own default title", () => { + const result = mergeMetadata([{ title: { template: "%s | Page", default: "Page" } }]); + + expect(result.title).toBe("Page"); }); it("preserves non-title metadata during merge", () => { @@ -2218,7 +2236,7 @@ describe("metadata title templates", () => { expect(result).toEqual({ description: "Page", openGraph: { title: "Slot OG title" }, - title: "Page", + title: "Page | Root", }); }); });