Skip to content

Commit b69844e

Browse files
fix(metadata): apply ancestor templates to title defaults (#1256)
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.
1 parent c84e383 commit b69844e

3 files changed

Lines changed: 62 additions & 43 deletions

File tree

packages/vinext/src/shims/metadata.tsx

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,29 @@ function resolveStringTitle(title: Metadata["title"]): string | undefined {
343343
return undefined;
344344
}
345345

346+
function applyTitleTemplate(template: string | undefined, title: string): string {
347+
return template ? template.replace(/%s/g, title) : title;
348+
}
349+
350+
function resolveTitle(title: Metadata["title"], stashedTemplate: string | undefined) {
351+
if (typeof title === "string") {
352+
return applyTitleTemplate(stashedTemplate, title);
353+
}
354+
355+
if (title && typeof title === "object") {
356+
let resolved =
357+
title.default === undefined ? undefined : applyTitleTemplate(stashedTemplate, title.default);
358+
359+
if (title.absolute) {
360+
resolved = title.absolute;
361+
}
362+
363+
return resolved;
364+
}
365+
366+
return undefined;
367+
}
368+
346369
/**
347370
* Post-process merged metadata to cross-fill openGraph and Twitter fields.
348371
*
@@ -400,7 +423,7 @@ export function postProcessMetadata(merged: Metadata): Metadata {
400423
if (!hasTwDescription) {
401424
autoFill.description = result.openGraph.description || result.description || undefined;
402425
}
403-
if (!hasTwImages) {
426+
if (!hasTwImages && result.openGraph.images !== undefined) {
404427
autoFill.images = result.openGraph.images;
405428
}
406429

@@ -456,25 +479,14 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me
456479

457480
const merged: Metadata = {};
458481

459-
// Track the most recent title template from LAYOUTS (not from page).
482+
// Track the most recent ancestor title template from layouts (not from page).
460483
let parentTemplate: string | undefined;
461484

462485
for (const entry of entries) {
463486
const meta = entry.metadata;
464487
const isPage = Boolean(entry.isPage);
465488
const contributesTitle = entry.contributesTitle !== false;
466489

467-
// Collect template from layouts only (page templates are ignored per Next.js spec)
468-
if (
469-
contributesTitle &&
470-
!isPage &&
471-
meta.title &&
472-
typeof meta.title === "object" &&
473-
meta.title.template
474-
) {
475-
parentTemplate = meta.title.template;
476-
}
477-
478490
// Merge non-title keys
479491
for (const key of Object.keys(meta)) {
480492
if (key === "title") continue; // Handle title separately below
@@ -492,30 +504,19 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me
492504

493505
// Title resolution
494506
if (contributesTitle && meta.title !== undefined) {
495-
merged.title = meta.title;
507+
merged.title = resolveTitle(meta.title, parentTemplate);
496508
}
497-
}
498509

499-
// Now resolve the final title, applying the parent template if applicable
500-
const finalTitle = merged.title;
501-
if (finalTitle) {
502-
if (typeof finalTitle === "string") {
503-
// Simple string title — apply parent template
504-
if (parentTemplate) {
505-
merged.title = parentTemplate.replace("%s", finalTitle);
506-
}
507-
} else if (typeof finalTitle === "object") {
508-
if (finalTitle.absolute) {
509-
// Absolute title — skip all templates
510-
merged.title = finalTitle.absolute;
511-
} else if (finalTitle.default) {
512-
// Title object with default — this is used when the segment IS the
513-
// defining layout (its own default doesn't get template-wrapped)
514-
merged.title = finalTitle.default;
515-
} else if (finalTitle.template && !finalTitle.default && !finalTitle.absolute) {
516-
// Template only with no default — no title to render
517-
merged.title = undefined;
518-
}
510+
// Collect the current layout template after resolving its own title so
511+
// title.default is wrapped by the ancestor template, not by its own template.
512+
if (
513+
contributesTitle &&
514+
!isPage &&
515+
meta.title &&
516+
typeof meta.title === "object" &&
517+
meta.title.template
518+
) {
519+
parentTemplate = meta.title.template;
519520
}
520521
}
521522

tests/app-page-head.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ describe("app page head resolution", () => {
383383
description: "Primary page",
384384
title: "Slot OG title",
385385
},
386-
title: "Page",
386+
title: "Page | Root",
387387
twitter: {
388388
card: "summary",
389389
description: "Primary page",

tests/features.test.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,21 @@ describe("metadata title templates", () => {
21332133
expect(result.title).toBe("My Site");
21342134
});
21352135

2136+
it("applies ancestor title template to child layout default title", () => {
2137+
// Next.js resolveTitle() applies the stashed ancestor template to title.default:
2138+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-title.ts
2139+
const result = mergeMetadataEntries([
2140+
{
2141+
metadata: { title: { template: "%s | Site", default: "Site" } },
2142+
},
2143+
{
2144+
metadata: { title: { default: "Blog" } },
2145+
},
2146+
]);
2147+
2148+
expect(result.title).toBe("Blog | Site");
2149+
});
2150+
21362151
it("title.absolute skips all templates", () => {
21372152
const result = mergeMetadata([
21382153
{ title: { template: "%s | My Site", default: "My Site" } },
@@ -2150,16 +2165,19 @@ describe("metadata title templates", () => {
21502165
expect(result.title).toBe("Hello World - Blog");
21512166
});
21522167

2153-
it("page template has no effect (page is terminal)", () => {
2154-
// If the page defines a template, it should be ignored
2155-
// Only layouts define templates, and page is always the last entry
2168+
it("applies ancestor template to page default while ignoring page template", () => {
21562169
const result = mergeMetadata([
21572170
{ title: { template: "%s | Site", default: "Site" } },
21582171
{ title: { template: "%s - Page Template", default: "Page Default" } },
21592172
]);
2160-
// The page's template should be ignored; the page's default is used
2161-
// because the page has a title object (not a string), so we use its default
2162-
expect(result.title).toBe("Page Default");
2173+
2174+
expect(result.title).toBe("Page Default | Site");
2175+
});
2176+
2177+
it("does not apply a page template to the page's own default title", () => {
2178+
const result = mergeMetadata([{ title: { template: "%s | Page", default: "Page" } }]);
2179+
2180+
expect(result.title).toBe("Page");
21632181
});
21642182

21652183
it("preserves non-title metadata during merge", () => {
@@ -2218,7 +2236,7 @@ describe("metadata title templates", () => {
22182236
expect(result).toEqual({
22192237
description: "Page",
22202238
openGraph: { title: "Slot OG title" },
2221-
title: "Page",
2239+
title: "Page | Root",
22222240
});
22232241
});
22242242
});

0 commit comments

Comments
 (0)