Skip to content

fix(metadata): apply ancestor templates to title defaults#1256

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-title-default-template
Open

fix(metadata): apply ancestor templates to title defaults#1256
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-title-default-template

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Overview

Field Details
Goal Match Next.js title resolution when title.default is provided under an ancestor title.template.
Core change Resolve each title value against the currently stashed ancestor template before stashing the current layout template for descendants.
Primary files packages/vinext/src/shims/metadata.tsx, tests/features.test.ts, tests/app-page-head.test.ts
Expected impact Nested layout and page title.default values now render with the same ancestor template semantics as Next.js.

Why

Metadata title resolution has a segment-local invariant: a layout template applies to descendant titles, while the segment's own title.default resolves against the ancestor template that was active before that segment. vinext collected layout templates first and resolved only the final title later, which meant object defaults skipped the stashed template for their own segment.

What changed

Scenario Before After
Root layout `{ template: "%s Site" }plus child layout{ default: "Blog" }` Blog
Page title.default under ancestor template default rendered without ancestor template default renders with ancestor template
Page title.template terminal page template remains non-stashed unchanged as a descendant template source
OpenGraph without images during Twitter auto-fill could materialize an own twitter.images: undefined property in resolved metadata leaves twitter.images absent unless there are images to inherit

Notes

The twitter.images guard is a narrow object-shape cleanup found while updating the app-head regression for the title fix. It avoids preserving an own undefined field when OpenGraph has no images; it is not intended to be a broader metadata object normalization pass.

This PR also does not attempt the deeper resolved-title architecture change where vinext would carry { absolute, template } through the full metadata pipeline like Next.js. The scope here is the visible final-title compatibility bug.

Validation

  • vp test run tests/features.test.ts -t "applies ancestor title template to child layout default title" first failed with Received: "Blog".
  • vp test run tests/app-page-head.test.ts -t "keeps primary page title handling independent from active parallel route metadata"
  • vp test run tests/features.test.ts -t "metadata title templates"
  • vp test run tests/nextjs-compat/metadata.test.ts
  • vp check

References

Reference Why it matters
Next.js resolve-title.ts Applies the stashed template to both string titles and title.default.
Next.js resolve-title.test.ts Covers object default title resolution with a stashed template.
Next.js metadata e2e title template tests Documents title-template behavior through rendered app routes.
Next.js metadata docs: title Documents title.default and title.template semantics.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1256

commit: 1809012

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.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/metadata-title-default-template branch from 8c7ee75 to 1809012 Compare May 16, 2026 07:03
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 07:22
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(metadata): apply ancestor templates to title defaults

This is a clean, well-scoped fix. The core insight is correct: the old code deferred all title resolution to a single post-loop pass, which meant title.default in a child layout or page never got wrapped by the ancestor template that was active before that segment. Moving to per-segment resolution inside the loop (resolve first, then stash the current segment's template for descendants) matches the Next.js resolveTitle() semantics documented in resolve-title.ts.

I traced through all the key scenarios:

Scenario Result Correct?
Root { template: "%s | Site", default: "Site" } + child { default: "Blog" } "Blog | Site" Yes
Root template + page { default: "Page", template: "..." } "Page | Root" (page template ignored for own default) Yes
Root template + page string title "About" "About | Site" Yes
title.absolute skips templates "Custom Title" Yes
Template-only layout { template: "..." } (no default) undefined (clears title, consistent with Next.js) Yes
Single page { template: "%s | Page", default: "Page" } "Page" (no ancestor template to apply) Yes

The twitter.images guard is a narrow, correct cleanup — prevents materializing an own twitter.images: undefined property when OG has no images.

Two small suggestions for test coverage below, neither blocking.

}

function applyTitleTemplate(template: string | undefined, title: string): string {
return template ? template.replace(/%s/g, title) : title;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this uses /%s/g (global replace), so a template like "%s - %s" would produce "Blog - Blog". The old code used a single .replace("%s", ...). Next.js's resolveTitleTemplate also does a single replacement:

// Next.js: resolve-title.ts
return template.replace(/%s/g, title)

Actually, checking again — Next.js does use /%s/g too (source). So this matches. Never mind, carry on.

return template ? template.replace(/%s/g, title) : title;
}

function resolveTitle(title: Metadata["title"], stashedTemplate: string | undefined) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type here is implicitly string | undefined. Consider annotating it explicitly as resolveTitle(...): string | undefined for clarity, since it feeds directly into merged.title which accepts both strings and title objects but the resolved value should only ever be a string or undefined at this point.

Comment thread tests/features.test.ts
},
]);

expect(result.title).toBe("Blog | Site");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test. Consider adding a companion test for the template-only (no default) edge case to lock in the intentional title-clearing behavior:

Suggested change
expect(result.title).toBe("Blog | Site");
expect(result.title).toBe("Blog | Site");
});
it("template-only layout (no default) clears previously set title", () => {
const result = mergeMetadataEntries([
{
metadata: { title: { template: "%s | Site", default: "Site" } },
},
{
metadata: { title: { template: "%s | Blog" } },
},
]);
expect(result.title).toBeUndefined();

Comment thread tests/features.test.ts
@@ -2150,16 +2165,19 @@ describe("metadata title templates", () => {
expect(result.title).toBe("Hello World - Blog");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — this correctly validates that a 3-layer scenario (root template → middle template → page string) applies the nearest template. The fact that the middle layout's default ("Blog") now also gets the root template applied is the core fix.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary:

Approved the PR. The fix correctly moves title resolution from a deferred post-loop pass to per-segment resolution inside the loop, matching Next.js's resolveTitle() semantics. The key change — resolving a segment's title.default against the ancestor template before stashing the segment's own template for descendants — is the right ordering.

I traced through all critical scenarios (string titles, object defaults, absolute titles, template-only layouts, single-page templates, nested templates) and confirmed they produce the correct output consistent with Next.js behavior.

Two non-blocking suggestions:

  1. Add an explicit return type annotation on resolveTitle for clarity
  2. Add a test for the template-only (no default) layout edge case to document the intentional title-clearing behavior

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants