Skip to content

fix(metadata): scope layout title templates to descendant segments - #3396

Open
camc314 wants to merge 7 commits into
cloudflare:mainfrom
camc314:codex/fix-layout-title-template-scope
Open

camc314 wants to merge 7 commits into
cloudflare:mainfrom
camc314:codex/fix-layout-title-template-scope

Conversation

@camc314

@camc314 camc314 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

A layout's title template currently applies to the page in its own route segment. Restrict template inheritance to descendant segments so a page uses the nearest ancestor segment's template. Layout titles, defaults, and other metadata continue to contribute, and callers supplying only an ordered module list retain their existing merge behavior. Development and production share this resolver.

Adds three route-aware regressions covering a colocated layout, a nested colocated layout, and a deeper descendant without its own layout, based on the Next.js metadata suite.

The source commit records all 4 focused upstream title cases passing, 377 app-page-head and feature tests passing, and passing targeted checks and package build. Tests were not rerun in this worktree because its local vite-plus dependency is not installed.

Upstream's metadata suite expects /title-template to use its page title
without the template declared by the layout in that same segment. A nested
page should use the nearest ancestor-segment template, not its colocated
layout's template. Vinext applied the last layout template in both cases.

Carry an explicit template-contribution flag on metadata merge entries and
set it from the layout tree position versus the page's route segments. Layout
metadata still contributes its own title/default and all other fields. Keep
ordered-list callers without segment metadata compatible with their existing
merge contract. Dev and production use the same app-page-head resolver.

Add three route-aware regressions: same-segment layout, nested colocated layout,
and a deeper descendant without its own layout. The first two failed before
this change and all now pass.

Validation:
- Original v16.2.6 title-template, stashed-title, and parent-layout-title cases:
  4 passed. The two baseline failures are now passing; the remaining metadata
  cases were not selected in this focused run.
- Full app-page-head and features suites: 377 passed.
- Targeted vp check and vinext package build pass.
Copilot AI lite review requested due to automatic review settings September 22, 2026 18:47

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pkg-pr-new

pkg-pr-new Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3396

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3396

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3396

vinext

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

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3396

commit: 5b4a72f

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5b4a72f against base 087f8d6 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.3 KB 142.3 KB ⚫ -0.0%
Client entry size (gzip) vinext 129.7 KB 129.7 KB ⚫ -0.0%
Dev server cold start vinext 2.50 s 2.47 s ⚫ -0.9%
Production build time vinext 2.75 s 2.74 s ⚫ -0.1%
RSC entry closure size (gzip) vinext 127.9 KB 128.1 KB ⚫ +0.2%
Server bundle size (gzip) vinext 226.0 KB 226.4 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

camc314 and others added 2 commits September 22, 2026 19:59
Next.js v16.2.6 expects the same-segment page title to remain Page. Update SSR and browser compatibility assertions, retaining descendant template coverage and linking the upstream test.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

Time limit: 8 minutes. Complete the review and post all findings within that limit.

// page sharing that layout's segment. Keep absent source positions
// compatible with callers that supply only an ordered module list.
contributesTitleTemplate:
options.routeSegments === 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.

[P2] Preserve callers when route-position data is absent

This fallback tests only routeSegments === undefined, although both routeSegments and layoutTreePositions are independently optional (and routeSegments explicitly permits null). For example, a caller with routeSegments: null and no positions previously received ordered-list template merging, but this now normalizes the route to [] and suppresses every layout template. Conversely, supplying segments without positions assigns every layout position 0, so same-segment templates are not scoped correctly. Please apply depth scoping only when both values (including the corresponding position) are present; otherwise retain the ordered merge behavior promised by the comment.

: [],
),
...(pageMetadata ? [{ isPage: true, metadata: pageMetadata }] : []),
...parallelMetadataResults

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.

[P2] Apply the same segment scoping to parallel-route metadata

resolveParallelRouteMetadata() returns a flat layout/page result list, and these entries still have neither isPage nor contributesTitleTemplate set. If the primary page has no title (a supported path covered later in this test file), an @slot/layout title template will therefore wrap the title from a colocated @slot/page, reproducing the same bug on the active parallel branch. Preserve each parallel entry’s page/layout role and tree position so the template can be suppressed for the slot page’s own segment too.

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

Time limit: 8 minutes. Complete the review and post all findings within that limit.

contributesTitleTemplate:
parallelRoute.routeSegments == null ||
layoutTreePositions[index] === undefined ||
layoutTreePositions[index] < routeSegments.length,

@ask-bonk ask-bonk Bot Sep 22, 2026 •

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.

[P2] Compare parallel positions in slot-local coordinates

For an ordinary direct slot, slot.routeSegments is null, so resolveActiveParallelRouteHeadInputs() substitutes the primary route full segments, while the slot-root layout keeps its slot-local position 0. In the new fixture this makes the condition effectively 0 < 2, so the @slot/layout.tsx template is still enabled and the result is Slot Page | Slot, not the asserted Slot Page. The unit test avoids this by supplying synthetic routeSegments: ["dashboard", "@slot"] with position 2, which is not the shape produced for a direct slot. Please retain the slot-local page depth (or translate both values to the same coordinate space) before deciding whether the layout is colocated.

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

Time limit: 8 minutes. Complete the review and post all findings within that limit.

contributesTitleTemplate:
parallelRoute.routeSegments == null ||
layoutTreePositions[index] === undefined ||
layoutTreePositions[index] < routeSegments.length,

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.

[P2] Use target-local positions for intercepted layouts

This comparison is still wrong for interception branches. resolveActiveParallelRouteHeadInputs() assigns every intercept layout options.routeSegments.length (the source route depth), while routeSegments here is interceptSourcePageSegments (the target filesystem path, including the slot/interception segments). For example, a one-segment source opening an intercepted page with a layout colocated beside that page gets 1 < targetPath.length, so its template wraps the intercepted page title even though this PR is meant to suppress same-segment templates. interceptLayoutSegments already carries each layout’s target-branch depth; please preserve/translate that depth so both sides of this test use the same coordinate system, and add an intercepted colocated-layout regression.

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

Time limit: 8 minutes. Complete the review and post all findings within that limit.

contributesTitleTemplate:
parallelRoute.routeSegments == null ||
layoutTreePositions[index] === undefined ||
layoutTreePositions[index] < routeSegments.length,

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.

[P2] Supply positions for slot-less sibling interceptions

The sibling-intercept path does not pass through the corrected position calculation: primaryParallelRouteHeadInput in app-page-element-builder.ts builds layoutModules and routeSegments but omits layoutTreePositions. Every sibling-intercept layout therefore takes the layoutTreePositions[index] === undefined compatibility branch here, so a template from a layout colocated with the intercepted page still wraps that page title. The new interception regression exercises a named @modal branch only. Please derive/pass target-local positions for the sibling-intercept head as well and cover that path.

// A layout template applies to descendant route segments, never the
// page sharing that layout's segment. Keep absent source positions
// compatible with callers that supply only an ordered module list.
contributesTitleTemplate:

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.

[P2] Scope templates in HTTP-access fallback metadata too

This scoping is only applied by prepareAppPageHeadInner; 404/403/401 rendering goes through resolveHttpAccessFallbackMetadata -> resolveOrderedAppPageMetadata, which still converts every source to a plain { metadata } entry. Thus a /foo/layout.tsx title template still wraps the title exported by a colocated /foo/not-found.tsx, although Next.js excludes the leaf layout template for the error-convention leaf as well. Please carry layout/page role and position through the ordered fallback plan (while preserving the documented no-position fallback) and add a not-found regression.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified against an isolated Next.js 16.2.7 app: with a colocated layout template and a not-found title, a real HTTP 404 renders <title>Fallback | Leaf</title>. vinext already matches that result. The error convention is an additional metadata leaf, so suppressing its layout template here would introduce a parity regression. I left this path unchanged and addressed the sibling-intercept finding separately in 5b4a72f.

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

Time limit: 8 minutes. Complete the review and post all findings within that limit.

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

LGTM!

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.

3 participants