Skip to content

fix(app-router): render templates with children only - #2322

Merged
james-elicx merged 6 commits into
mainfrom
codex/fix-client-template-navigation-parity
Jun 26, 2026
Merged

james-elicx merged 6 commits into
mainfrom
codex/fix-client-template-navigation-parity

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • stop passing params to App Router templates, matching Next.js's children-only template contract
  • allow client template.tsx modules to serialize and mount instead of failing on vinext's thenable params object
  • add focused Next.js v16.2.6 parity coverage for client template state reset and server template rerendering

Root cause

vinext rendered every template as <TemplateComponent params={options.matchedParams}>. For client templates, matchedParams is a thenable/non-plain object and React Flight rejects it before the template can mount. Next.js constructs templates with only the template context child.

This track intentionally avoids the layout persistence and soft back/forward work already covered by #2316.

Next.js reference

  • test/e2e/app-dir/app/index.test.ts at tag v16.2.6
  • packages/next/src/server/app-render/create-component-tree.tsx at tag v16.2.6

Validation

  • vp test run tests/app-page-route-wiring.test.ts — 38 passed
  • targeted template-navigation.spec.ts with one worker — 2 passed
  • existing layout-persistence.spec.ts --grep "Template remount" with one worker — 3 passed
  • focused vp check — clean
  • vp run vinext#build — passed
  • independent self-review — no findings

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 170b128

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot 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.

/bigbonk review

Verdict: LGTM. The change is correct and well-targeted.

Root cause & fix are correct

Verified against Next.js v16.2.6 source. create-component-tree.tsx constructs the template node as:

const templateNode = createElement(
  Template,
  null,                                    // <-- no props, no params
  createElement(RenderFromTemplateContext, null)
)

The PR's change from <TemplateComponent params={options.matchedParams}> to <TemplateComponent> exactly matches Next.js's children-only template contract. This is the right fix for the reported bug: client template.tsx modules failing because matchedParams is a thenable/non-plain object that React Flight rejects.

Verification performed

  • options.matchedParams is still used in 8 other locations (layouts, slots, route state key, etc.) — no dead code introduced; change is correctly scoped to templates only.
  • app-page-route-wiring.tsx:680-703 is the single construction site for templates. The App Router prod server delegates to this shared wiring, so there's no dev/prod parity gap to chase elsewhere.
  • Ran vitest run tests/app-page-route-wiring.test.ts locally — 38 passed. The new expect(lastGroupTemplateProps).not.toHaveProperty("params") is a solid regression guard, and children is still threaded through (readChildren continues to work).

Minor / non-blocking observations

  1. Client spec lines 21-24 (template-navigation.spec.ts): line 21 increments then line 22 immediately clicks the link without asserting the intermediate count. The intent (prove count resets to 0 after navigating back via a remounted template) holds because line 24 asserts the freshly-mounted template starts at 0. Not a bug, but adding an intermediate toHaveText("Template 1") assertion before navigating would make the test's intent more legible and rule out a no-op increment masking a regression.
  2. Shared testid client-template-link across both page.tsx and other/page.tsx is fine since only one page is mounted at a time, but worth noting for future maintainers.

Both are optional polish; nothing blocks merge.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2322.

Summary

Verdict: LGTM — the change is correct and well-scoped.

What I verified:

  • The fix matches Next.js v16.2.6 exactly. create-component-tree.tsx builds the template node with createElement(Template, null, ...) — null props, no params. Dropping params={options.matchedParams} from TemplateComponent (app-page-route-wiring.tsx:685-698) is the correct children-only contract.
  • The root cause stated in the PR is accurate: matchedParams is a thenable/non-plain object that React Flight rejects for client templates.
  • options.matchedParams is still used in 8 other spots (layouts, slots, route-state key) — no dead code, change is template-scoped.
  • This is the single template-construction site; the App Router prod server delegates here, so no dev/prod parity gap.
  • Ran vitest run tests/app-page-route-wiring.test.ts locally → 38 passed. The new not.toHaveProperty("params") assertion is a good regression guard.

Non-blocking observations (posted in the review):

  1. Client spec lines 21-24 would read more clearly with an intermediate count assertion before navigating back.
  2. Shared client-template-link testid across both pages is fine (one page mounted at a time) but worth noting.

Nothing blocks merge.

github run

@github-actions

github-actions Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 170b128 against base de014d4 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 117.1 KB 117.1 KB ⚫ -0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ -0.0%
Dev server cold start vinext 2.49 s 2.47 s ⚫ -0.8%
Production build time vinext 3.02 s 3.00 s ⚫ -0.8%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 156.9 KB 156.9 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot 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.

/bigbonk review — independent verification

Verdict: LGTM. Verified the fix against Next.js v16.2.6 source and ran the tests locally.

Fix is correct

Confirmed against packages/next/src/server/app-render/create-component-tree.tsx at tag v16.2.6. Next.js builds the template node as:

const templateNode = createElement(
  Template,
  null,                                       // no props, no params
  createElement(RenderFromTemplateContext, null)
)

The PR's change from <TemplateComponent params={options.matchedParams}> to <TemplateComponent> (app-page-route-wiring.tsx:689,695) exactly matches this children-only contract. The stated root cause is accurate: matchedParams is a thenable/non-plain object that React Flight rejects before a client template can mount.

Verification performed

  • Single construction site. Lines 689 and 695 are the two branches of the same template loop and both were updated. The App Router prod server delegates to buildAppPageElements, so there is no dev/prod parity gap.
  • No dead code. options.matchedParams is still consumed for layouts, slots, and state keys.
  • Unit tests: vp test run tests/app-page-route-wiring.test.ts → 38 passed. The new expect(lastGroupTemplateProps).not.toHaveProperty("params") is a good regression guard, and children is still threaded through.
  • E2E: ran template-navigation.spec.ts (project app-router, 1 worker) → 2 passed. Verified the subtle server-template assertion: I initially suspected the parent-segment template would not remount on alpha→beta, but the template's templateTreePosition keys it to the [section] boundary via segmentResetKey, so the reset-to-0 assertion is correct.

Non-blocking observations

  1. Client spec lines 21-24 (template-navigation.spec.ts): line 21 increments then line 22 immediately navigates without an intermediate count assertion. An added toHaveText("Template 1") before navigating would make the intent clearer and rule out a no-op increment masking a regression.
  2. Shared client-template-link testid across page.tsx and other/page.tsx is fine (one page mounted at a time) but worth a note for future maintainers.

Nothing blocks merge.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2322.

Summary

Verdict: LGTM — the change is correct and well-scoped.

What I verified:

  • Fix matches Next.js v16.2.6 exactly. Fetched create-component-tree.tsx at tag v16.2.6: Next.js builds the template as createElement(Template, null, ...) — null props, no params. Dropping params={options.matchedParams} from TemplateComponent (app-page-route-wiring.tsx:689,695) is the correct children-only contract.
  • Root cause accurate — matchedParams is a thenable/non-plain object React Flight rejects for client templates.
  • No dead code — options.matchedParams still used for layouts, slots, state keys.
  • Single construction site, prod delegates to buildAppPageElements → no dev/prod parity gap.
  • Tests pass locally — 38 unit tests + 2 e2e tests (had to install chromium first).

Notable: I initially suspected the server-template parity assertion (count resets 0 on alpha→beta despite the template living at a parent segment) might be wrong, but confirmed it's correct: the template is keyed by its templateTreePosition via segmentResetKey, which tracks the [section] boundary. The test passed empirically.

Non-blocking observations (posted in review): intermediate count assertion in the client spec, and the shared client-template-link testid. Nothing blocks merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot 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.

/bigbonk review for issues

Verdict: LGTM. No blocking issues. The fix is correct and matches Next.js v16.2.6 exactly.

Fix is correct

Verified against packages/next/src/server/app-render/create-component-tree.tsx at tag v16.2.6. Next.js builds the template node with no props:

const templateNode = createElement(
  Template,
  null,                                       // no props, no params
  createElement(RenderFromTemplateContext, null)
)

Dropping params={options.matchedParams} from both branches of TemplateComponent (app-page-route-wiring.tsx:689,695) is the correct children-only contract. The stated root cause holds: matchedParams is a thenable produced by makeThenableParams, a non-plain object React Flight rejects before a client template.tsx can mount.

Verification performed

  • No dead code. options.matchedParams is still consumed in 7 other spots — resolveAppPageRouteStateKey (route reset key, L545), slot param override (L638), layout params (L714), slot params (L771), and segment reset keys (L1010, L1084). The change is scoped to templates only.
  • Single construction site / no dev-prod gap. Lines 687–698 are the only place templates are built; the App Router prod server delegates to buildAppPageElements, so there is no Pages-Router-style parity gap to chase in prod-server.ts.
  • Unit test passes locally. vitest run tests/app-page-route-wiring.test.ts → 38 passed. The new expect(lastGroupTemplateProps).not.toHaveProperty("params") is a solid regression guard, and children is still threaded through (readChildren keeps working).
  • E2E green in CI. All three app-router shards pass, which exercise the new template-navigation.spec.ts.

On the subtle server-template assertion (L42-44)

I dug into whether alpha→beta should reset the server template's client identity to 0, since the template.tsx lives at the static template-server/ segment, not at [section]. vinext's segmentResetKey for that template resolves to a constant (resolveAppPageSegmentStateKey returns the first non-group segment from the template's own treePosition, i.e. "template-server"), so the <Slot key=...> does not change on a param-only navigation. The reset to 0 therefore comes from the full subtree re-render on param navigation, not from the key — and CI confirms it matches Next.js empirically. Worth a one-line comment in the spec noting the reset is driven by the subtree rebuild, not the template key, so a future refactor of segmentResetKey doesn't silently regress this guarantee. Non-blocking.

Minor / non-blocking

  1. Unused fixture link. template-server/page.tsx defines data-testid="server-template-link" that the spec never clicks (the test starts directly at /template-server/alpha). Harmless, but it's dead test scaffolding.
  2. Client spec L21-24. Line 21 increments then L22 navigates without asserting the intermediate count — adding toHaveText("Template 1") before navigating back would make the reset-on-remount intent clearer and rule out a no-op increment masking a regression.
  3. Shared client-template-link testid across page.tsx and other/page.tsx is fine (one page mounted at a time) but worth a note for future maintainers.

Nothing blocks merge.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2322.

Summary

Verdict: LGTM — the change is correct and well-scoped. No blocking issues.

What I verified:

  • Fix matches Next.js v16.2.6 exactly. Fetched create-component-tree.tsx at tag v16.2.6: Next.js builds the template node as createElement(Template, null, createElement(RenderFromTemplateContext, null)) — null props, no params. Dropping params={options.matchedParams} from both branches of TemplateComponent (app-page-route-wiring.tsx:689,695) is the correct children-only contract.
  • Root cause accurate — matchedParams is a makeThenableParams thenable React Flight rejects for client templates.
  • No dead code — options.matchedParams is still used in 7 other spots (route reset key, slot overrides, layout/slot params, segment reset keys).
  • Single construction site, no dev/prod gap — prod delegates to buildAppPageElements.
  • Unit tests pass locally — 38 passed; new not.toHaveProperty("params") is a good regression guard.
  • E2E green in CI — all three app-router shards pass.

Notable finding I investigated: the server-template parity assertion (alpha→beta resets client identity to 0) is correct, but not because of the Slot key. vinext's segmentResetKey for that template resolves to a constant ("template-server", the template's own static segment), so the key doesn't change on param navigation. The reset comes from the full subtree re-render. I flagged this in the review with a suggestion to add a comment so a future segmentResetKey refactor doesn't silently regress it.

Non-blocking observations posted: unused server-template-link fixture testid, missing intermediate count assertion in the client spec, and the shared client-template-link testid. Nothing blocks merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot 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.

/bigbonk review for issues

Verdict: LGTM. The core fix is correct and matches Next.js v16.2.6 exactly. One genuine latent-correctness finding on the new server-template test that's worth recording, plus minor test-scaffolding cleanup. Nothing blocks merge.

Core fix is correct

Verified against packages/next/src/server/app-render/create-component-tree.tsx at v16.2.6. Next.js builds the template node with no props:

const templateNode = createElement(Template, null, createElement(RenderFromTemplateContext, null))

Dropping params={options.matchedParams} from both branches of TemplateComponent (app-page-route-wiring.tsx:689,695) is the correct children-only contract. Root cause is accurate — matchedParams is a makeThenableParams thenable React Flight rejects for client templates.

  • Single construction site / no dev-prod gap. buildAppPageElements (L680–703) is the only place templates are built; I grepped the whole packages/vinext/src tree — there is no second TemplateComponent/template-with-params construction in prod-server.ts or app-rsc-entry.ts. Prod delegates to this shared wiring.
  • No dead code. options.matchedParams still feeds layouts (L714), slots (L771), and segment/route reset keys.
  • Unit tests pass locally — vitest run tests/app-page-route-wiring.test.ts → 38 passed. not.toHaveProperty("params") is a solid regression guard.

Finding: the server-template assertion is right per the docs, but vinext's key mechanism is not what makes it pass (latent risk)

The new test asserts that navigating template-server/alpha → template-server/beta resets server-template-identity to 0 (template-navigation.spec.ts:42-44). I checked this against the Next.js template.js docs:

Templates receive a unique key for their own segment level. They remount when that segment (including its dynamic params) changes. Navigations within deeper segments do not remount higher-level templates.

And the concrete blog example: app/blog/template.tsx remounts on /blog/first-post → /blog/second-post because its key goes from /blog/first-post to /blog/second-post — i.e. the template's key includes the changing child dynamic segment. So the assertion that the template-server template remounts on alpha→beta is correct Next.js behavior, and the test is asserting the right thing. Good.

But here's the catch: vinext's segmentResetKey for that template comes from resolveAppPageSegmentStateKey(routeSegments, treePosition, params) (app-page-segment-state.ts:117-128), which returns the first non-group segment at the template's tree position — i.e. the constant "template-server". It does not include the child [section] param. So vinext's <Slot key={segmentResetKey}> (app-page-route-wiring.tsx:1068) does not change on alpha→beta. The reset-to-0 the test observes comes from the full subtree re-render on param navigation, not from the template key changing as Next.js's blog example shows.

That means this green test is a coincidental pass over a real keying divergence: per the docs a parent template's key should track its changing child segment, but vinext keys it to a constant. A future refactor that relies on the Slot key for remount (instead of subtree rebuild) would silently regress this guarantee, and the test wouldn't catch the mechanism breaking. This keying gap is pre-existing (not introduced by this PR, which is scoped to the children-only contract and defers soft-nav/persistence to #2316), so it does not block. Recommend either (a) a one-line comment in the spec noting the reset is driven by subtree rebuild, not the template key, or (b) filing a follow-up issue to align resolveAppPageSegmentStateKey for templates with Next.js's child-segment-inclusive keying.

Minor / non-blocking

  1. Dead test scaffolding. template-server/page.tsx and its data-testid="server-template-link" are never referenced — the spec navigates directly to /template-server/alpha. Confirmed via grep: server-template-link has exactly one occurrence (the fixture) and zero in tests. Either drop the fixture page or add a test that starts at /template-server and clicks through it.
  2. Client spec L21-24. L21 increments then L22 navigates without asserting the intermediate count. An added toHaveText("Template 1") before navigating would rule out a no-op increment masking a regression.
  3. Shared client-template-link testid across page.tsx and other/page.tsx is fine (one page mounted at a time) but worth a note for maintainers.

Nothing blocks merge.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2322.

Summary

Verdict: LGTM — the core fix is correct and well-scoped. Nothing blocks merge.

What I verified:

  • Fix matches Next.js v16.2.6 exactly. Fetched create-component-tree.tsx at the tag: Next.js builds the template as createElement(Template, null, ...) — no params. Dropping params={options.matchedParams} from both branches at app-page-route-wiring.tsx:689,695 is the correct children-only contract. Root cause (thenable matchedParams rejected by React Flight) is accurate.
  • Single construction site, no dev/prod gap — grepped the whole source tree; buildAppPageElements is the only template construction site, shared by dev and prod.
  • No dead code — matchedParams still feeds layouts, slots, and reset keys.
  • Unit tests pass locally — 38 passed; not.toHaveProperty("params") is a good regression guard.

The one substantive finding (which I dug deeper on than prior reviews): the server-template assertion (alpha→beta resets identity to 0) is correct per the Next.js template.js docs (a parent template's key includes its changing child dynamic segment — the docs' blog /blog/first-post→/blog/second-post example). But vinext's resolveAppPageSegmentStateKey keys that template to the constant "template-server", so the Slot key does not change on alpha→beta — the reset comes from a full subtree re-render, not the key. So it's a green test passing over a real, pre-existing keying divergence (not introduced by this PR, which defers soft-nav work to #2316). I recommended either a clarifying comment in the spec or a follow-up issue.

Minor/non-blocking: dead template-server/page.tsx fixture + unused server-template-link testid, missing intermediate count assertion in the client spec, and the shared client-template-link testid.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 25, 2026 12:39
@james-elicx
james-elicx merged commit da17985 into main Jun 26, 2026
58 checks passed
@james-elicx
james-elicx deleted the codex/fix-client-template-navigation-parity branch June 26, 2026 13:51
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.

1 participant