Skip to content

fix(app-router): retain shared layouts across loading shells - #2940

Merged
james-elicx merged 4 commits into
mainfrom
codex/fix-retain-shared-layout
Aug 17, 2026
Merged

fix(app-router): retain shared layouts across loading shells#2940
james-elicx merged 4 commits into
mainfrom
codex/fix-retain-shared-layout

Conversation

@james-elicx

@james-elicx james-elicx commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

  • retain mounted layouts while an optimistic loading shell omits them only when their target-side segment identities are unchanged
  • preserve remount/loading behavior for genuinely changed dynamic params and parallel-slot branches
  • derive optimistic identities from the same canonical raw route params as authoritative RSC matching, including encoded slashes, UTF-8, percent signs, double encoding, and catch-all arrays
  • cover active, default, empty, and overridden parallel slots plus target-side identity reification

This PR includes the production Cloudflare Worker E2E reproduction originally added in #2939, so the regression and fix land together.

Root cause and Next.js parity

A loading-shell prefetch carries the route's layout identities, but its rendered element stops at the first loading boundary and can omit mounted layouts below that boundary. Vinext committed the shell before the authoritative RSC payload, disconnecting those layouts and resetting their client state even when the destination reused the same layout segment.

Next.js keys each layout-router branch by segment identity. Navigating below an unchanged layout preserves that branch; changing its dynamic segment or parallel-slot branch replaces it. The direct behavioral oracle is test/e2e/app-dir/app-prefetch-false-loading/app-prefetch-false-loading.test.ts, backed by layout-router.tsx.

The fix records omitted named-slot segments from existing BFCache identities, then reifies those identities from the target route rather than copying learned source identities. Before committing a loading shell, vinext compares the mounted and target identities: unchanged omitted layouts keep rendering until the authoritative payload arrives, while changed params or slot branches retain normal loading/remount behavior. Optimistic and authoritative matching now share canonical raw-param extraction so encoded dynamic values cannot disagree about identity.

Validation

  • 400 focused unit tests across optimistic routing, page wiring, segment state, RSC matching, route patterns, and shims
  • 1,473-test independent exact-head parity review pass across the cumulative diff
  • production app-router-cloudflare build served with Wrangler plus layout-identity.spec.ts — 6 passed
  • changed-file vp check, Knip, vp run vinext#build, and git diff --check
  • four independent exact-head review lanes (Next.js parity, route identity, navigation lifecycle, and regression coverage) — no findings

Add a `/layout-identity` fixture to the app-router-cloudflare example and
e2e coverage showing that a shared `[slug]` layout is discarded during a
soft navigation that only changes segments below it.

The fixture pairs the shared layout with a `loading.tsx` boundary above
it, a parallel slot beside `children`, and a nested dynamic segment with
its own layout, matching the shape a real app has. Removing the
`loading.tsx` boundary makes every assertion pass, which isolates it as
the trigger.

The five layout-identity assertions fail on main; the soft-navigation
control passes, so the teardown is the router discarding a layout it
should have retained rather than a full document load.
@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2940
npm i https://pkg.pr.new/create-vinext-app@2940
npm i https://pkg.pr.new/@vinext/types@2940
npm i https://pkg.pr.new/vinext@2940

commit: 3385367

@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

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 3385367 against base 9a0e8e8 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 135.4 KB 137.1 KB ⚫ +1.2%
Client entry size (gzip) vinext 122.8 KB 124.5 KB ⚫ +1.3%
Dev server cold start vinext 3.19 s 3.16 s ⚫ -1.1%
Production build time vinext 3.43 s 3.41 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 114.9 KB 114.9 KB ⚫ -0.0%
Server bundle size (gzip) vinext 195.2 KB 195.2 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.

Review: retain shared layouts across loading shells

I reviewed the two commits (retain shared layouts + preserve omitted slot segment identity), traced the new identity derivations against the authoritative mint path in app-page-segment-plan.ts, ran the targeted suites, and confirmed vp check is clean.

Verdict

Looks correct. The core design decision — re-deriving each omitted segment's identity for the target params in canCommitOptimisticRouteTemplate rather than trusting the stale identity baked into the learned shell — is the right call and is well tested (does not commit a slot loading shell over a retained nested slot segment, lines 807-834 exercise the beta-learned/alpha-target case). Same-param retention returns false (wait for authoritative payload) and changed-param transitions return true (commit shell), matching the Next.js parity target.

Things I verified

  • Identity derivation parity. resolveTargetBfcacheSegmentIdentity uses binding.slotId / binding.ownerLayoutId and the layout check uses layout.id. I confirmed these equal the graph-mode minted values: graphIds.slots[slotKey] === slot.id === binding.slotId (app-route-graph.ts:1867, :739), and requireGraphSequenceId(graphIds.layouts, i) === route.ids.layouts[i] === layout.id (app-route-graph.ts:540, :1875). So the derived identities match what deriveSlotIdentity mints for both slot and layout kinds, including the bindOwnerState / ownerStateKey composition and the empty-branch level-0 fallback.
  • Refactor equivalence. extractRawParamsForMatchedRoute -> shared extractRawRoutePatternParams, and canonicalizeAppPageParams moved to app-page-segment-state.ts, are behavior-preserving (Object.create(null), same catch-all/decode semantics). No circular import introduced (app-page-segment-state.ts only imports a type + utils). Types are structurally identical (Record<string, string | string[]>).
  • Tests. app-optimistic-routing (16), app-page-segment-state (8), app-page-route-wiring (79), app-browser-entry (234) all pass locally; vp check clean on the changed source + test files.

Minor notes (non-blocking)

  1. Fallback param canonicalization asymmetry. In canCommitOptimisticRouteTemplate, the no-identity fallback (app-optimistic-routing.ts:553-558) compares resolveAppPagePatternStateKey(layout.patternParts, currentParams) against targetRouteParams. targetRouteParams is canonicalized (canonicalizeAppPageParams) but currentParams (navigationSnapshot.params) is whatever the prior navigation stored. For normal param values this is a no-op, but a pre-encoded current value could produce a spurious "changed" verdict and commit the shell over a retained layout. This only affects the pre-bfcache-identity path (first render), so impact is narrow — but canonicalizing currentParams before the fallback comparison would make it airtight.

  2. omittedLayoutIds fallback pattern-vs-prefix equivalence. The fallback relies on resolveAppPagePatternStateKey(layout.patternParts, ...) producing the same key sequence as the mint's resolveAppPageRouteStateKey(routeSegments.slice(0, treePosition), ...). This holds because route groups are excluded from both, but it's an implicit invariant worth a one-line comment so a future change to patternParts derivation doesn't silently break the fallback.

Neither note blocks merge; the primary (derived-identity) path is robust and the fallback is only hit before identities exist.

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2940.

Summary

I reviewed PR #2940 (fix(app-router): retain shared layouts across loading shells) — both commits, tracing the new identity-derivation logic against the authoritative mint path, and ran the targeted test suites.

Verdict: Looks correct. No blocking issues found.

Key things verified

  • Identity parity (the highest-risk area): The new resolveTargetBfcacheSegmentIdentity and the layout comparison in canCommitOptimisticRouteTemplate use binding.slotId, binding.ownerLayoutId, and layout.id. I confirmed these exactly equal the graph-mode values minted by deriveSlotIdentity/deriveBfcacheSegmentIdentity in app-page-segment-plan.ts (graphIds.slots[slotKey] === slot.id === binding.slotId, graphIds.layouts[i] === layout.id), including the ownerStateKey/bindOwnerState composition and empty-branch fallback. So the retention check re-derives target identities that will actually match minted ones — and importantly does not trust the stale identity baked into the learned shell (well-tested via the beta-learned/alpha-target case).
  • Refactor is behavior-preserving: extractRawParamsForMatchedRoute → shared extractRawRoutePatternParams, and canonicalizeAppPageParams relocated to app-page-segment-state.ts (no circular import, identical types).
  • Tests: app-optimistic-routing (16), app-page-segment-state (8), app-page-route-wiring (79), app-browser-entry (234) all pass; vp check clean.

Minor non-blocking notes I raised

  1. Fallback param comparison asymmetry — targetRouteParams is canonicalized but currentParams may not be; only affects the pre-bfcache-identity first-render path.
  2. An implicit invariant (pattern-parts vs route-prefix state-key equivalence) worth a comment to guard against future regressions.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@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.

Review: retain shared layouts across loading shells

Traced the new identity-derivation logic against the authoritative mint path in app-page-segment-plan.ts / app-rsc-route-matching.ts, ran the targeted suites (app-optimistic-routing 16, app-page-segment-state 8 — all green), and confirmed the refactor is behavior-preserving.

The core design — re-deriving each omitted segment's identity for the target params rather than trusting the stale identity baked into the learned shell — is correct and well tested for the primary (bfcache-identity) path. But I found one concrete correctness gap in the fallback path.

Finding: fallback path resets shared layout state for encoded params (bug, narrow)

canCommitOptimisticRouteTemplate at packages/vinext/src/server/app-optimistic-routing.ts:553-558:

if (
  resolveAppPagePatternStateKey(layout.patternParts, options.currentParams) ===
  resolveAppPagePatternStateKey(layout.patternParts, options.targetRouteParams)
) {
  return false;
}

targetRouteParams is canonicalized (resolveOptimisticNavigationParams calls canonicalizeAppPageParams, matching the authoritative mint which canonicalizes at app-rsc-route-matching.ts:185). But currentParams is navigationInitiationState.navigationSnapshot.params (app-browser-entry.ts:2063), which is the decoded param map from the prior RSC payload (applyClientParams stores decoded values; decodeMatchedParams runs on match). The two sides are derived from param maps in different encodings, so their state keys diverge for any param whose decoded and canonical forms differ.

Concrete repro — navigating within a shared /projects/[projectId] layout where projectId contains an encoded slash (/projects/a%2Fb/activity):

current key: ["projects","projectId|a/b|d"]     // decoded currentParams
target  key: ["projects","projectId|a%2Fb|d"]   // canonicalized targetRouteParams
equal? false  =>  canCommit returns true  =>  commits shell, resets shared layout state

This is the exact parity bug the PR fixes, surviving in the pre-bfcache-identity fallback (first navigation after a full page load, before identities are minted onto the mounted elements). The same asymmetry would apply to any encoded value (UTF-8, %, double-encoding), i.e. the very cases the summary calls out as covered.

Why the tests don't catch it: the encoded-param assertions in tests/app-optimistic-routing.test.ts:628-656 pass currentParams: { projectId } where projectId is the already-encoded string ("a%2Fb"), so both sides canonicalize identically and the fallback returns false. In production currentParams would hold the decoded "a/b". The wiring tests use currentParams: {}. So the asymmetry is never exercised with a realistic decoded snapshot.

Fix: canonicalize a copy of currentParams before the fallback comparison (mirroring how the identity path already relies on canonicalized target params). A one-line const currentParamsCanonical = { ...options.currentParams }; canonicalizeAppPageParams(currentParamsCanonical); used on the fallback's left-hand side makes both sides encoding-consistent, and a regression test should pass a decoded currentParams with an encoded target to lock it in.

Notes (non-blocking)

  • Implicit invariant, omittedLayoutIds fallback + identity path. Both rely on resolveAppPagePatternStateKey(layout.patternParts, ...) producing the same key sequence as the mint's resolveAppPageRouteStateKey(routeSegments.slice(0, treePosition), ...). This holds only because route groups are excluded from both (patternParts never contains them; resolveAppPageRouteStateKey skips them). Worth a one-line comment so a future change to patternParts derivation doesn't silently desync the retention check from minted identities.

  • Refactor is clean. extractRawParamsForMatchedRoute -> shared extractRawRoutePatternParams and the canonicalizeAppPageParams relocation preserve semantics (Object.create(null), identical catch-all/decode handling, no circular import).

Verdict: primary path is solid; the fallback param-canonicalization gap is a real (if narrow) state-reset regression under encoded params and should be closed with a decoded-currentParams regression test.

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2940.

Summary

I reviewed PR #2940 (fix(app-router): retain shared layouts across loading shells), tracing the new identity-derivation logic against the authoritative mint path and running the targeted suites (all green).

Verdict: The primary (bfcache-identity) path is correct and well tested. I found one concrete, narrow correctness gap in the fallback path.

Finding (bug, narrow)

In canCommitOptimisticRouteTemplate's no-identity fallback (app-optimistic-routing.ts:553-558), targetRouteParams is canonicalized (matching the authoritative mint) but currentParams (navigationSnapshot.params) is the decoded prior-navigation snapshot. For any param whose decoded and canonical forms differ (encoded slash, UTF-8, %, double-encoding), the two derived state keys diverge, so canCommit returns true and commits the loading shell over a retained shared layout — resetting its client state. This is the exact parity bug the PR fixes, surviving in the pre-identity fallback (first navigation after a full page load).

I verified with a concrete repro: /projects/a%2Fb/activity yields projectId|a/b|d (current) vs projectId|a%2Fb|d (target) → not equal → wrong commit.

The added encoded-param tests mask this by hardcoding the encoded value into currentParams; production holds the decoded value. Fix is a one-liner (canonicalize a copy of currentParams before the fallback comparison) plus a regression test that passes a decoded currentParams with an encoded target.

Non-blocking

  • Implicit patternParts vs routeSegments-prefix state-key equivalence invariant worth a comment.
  • Refactor (extractRawRoutePatternParams, canonicalizeAppPageParams relocation) is behavior-preserving.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Investigated this finding against the full runtime data flow; no code change is warranted because the premise that currentParams is decoded for App Pages is incorrect.

navigationSnapshot.params is seeded from the App Page RSC params. createAppRscRouteMatcher canonicalizes App Page params (while only Route Handlers use decodeMatchedParams), buildAppPageRscResponse serializes those canonical values unchanged in X-Vinext-Params, and the browser only parses/stores that JSON. Initial hydration, authoritative navigation, optimistic payloads, visited-response/cache paths, history, and HMR all preserve this canonical representation. The existing matcher tests cover %2F, UTF-8, %25, and double encoding; the Worker E2E also verifies /blog/a%2Fb exposes a%2Fb rather than a/b.

The suggested decode/re-encode of a supposedly decoded current value is not generally recoverable for double encoding or raw percent values: after decoding, %2F cannot distinguish an originally single-encoded value from a decoded %252F. The correct invariant is therefore the one implemented here: both App Page sides use the canonical raw-param representation from their route-match boundaries. Missing/malformed headers produce {}, not decoded params.

@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.

Review: retain shared layouts across loading shells

I independently traced the new identity-derivation logic against the authoritative mint path, resolved the disputed encoded-param "fallback bug" against the real runtime data flow, and ran the targeted suites (app-optimistic-routing 16, app-page-segment-state 8, app-page-route-wiring 79, route-pattern 25 — all green; vp check clean on all changed source).

Verdict: looks correct. No blocking issues.

The earlier bonk finding (fallback path resets shared-layout state for encoded params) does not reproduce, and the author's rebuttal is right. I verified the full param encoding lifecycle rather than taking either side at face value:

currentParams (navigationSnapshot.params) is canonical for App Pages, not decoded.

  • App Page route matching canonicalizes at app-rsc-route-matching.ts:185 (normalizeMatchedParamsForRoutecanonicalizeAppPageParams; only Route Handlers take the decodeMatchedParams branch).
  • navigationParams is { ...routeParams } seeded from those canonical matched params (app-page-element-builder.ts:932, fed by the matcher at app-page-dispatch.ts:1066 / options.params).
  • buildAppPageRscResponse serializes them at app-page-response.ts:309 as encodeURIComponent(JSON.stringify(params)) — the encodeURIComponent is a transport wrapper around the JSON string, not a per-value re-encode. The values inside stay canonical (a%2Fb).
  • The browser reverses only that wrapper: parseEncodedJsonHeader does JSON.parse(decodeURIComponent(value)) (app-browser-entry.ts:325), and applyClientParams / createClientNavigationRenderSnapshot store the parsed values verbatim (no per-value decode).
  • The initial-hydration path (__VINEXT_RSC_PARAMS__) is injected from the same canonical navigationParams.

So both sides of the fallback comparison (currentParams and canonicalized targetRouteParams) are canonical. /projects/a%2Fb/activity yields projectId=a%2Fb on both sides → equal → correctly returns false (retain). The suggested decode/re-encode would actually be wrong: after decoding, %2F can't distinguish a single-encoded value from a decoded %252F, so the canonical-on-both-sides invariant this PR implements is the correct one. The encoded-param tests hardcoding the encoded value into currentParams are therefore realistic, not masking.

Identity parity (highest-risk area) — verified sound

resolveTargetBfcacheSegmentIdentity re-derives each omitted segment's identity for the target params rather than trusting the stale identity baked into the learned shell. I confirmed the derived identities equal the authoritative graph-mode mint in app-page-segment-plan.ts:

  • Graph-id equivalence holds. graphIds.slots[slotKey] === slot.id === binding.slotId (createAppRouteSemanticIds sets slots[slot.key] = slot.id at app-route-graph.ts:1867; createRouteManifestSlotBinding sets slotId: slot.id at :739). binding.ownerLayoutId is already a graph layout id (findRouteManifestOwnerLayoutIdroute.ids.layouts[i]), and resolveOwnerLayoutGraphId maps a graph layout id back to itself. So passing binding.slotId / binding.ownerLayoutId / layout.id directly equals the graphIds-resolved values the mint uses. If these ever diverged, the fix would silently no-op (never retain) rather than crash — but they don't.
  • Slot-param encoding parity holds. The mint's slot-override params come from resolveSlotParamOverridesmatchRoutePattern (decoded); the optimistic path's needsSlotParamOverride branch also uses matchRoutePattern(targetUrlParts, slotPatternParts) (decoded). The non-override branch uses canonical targetRouteParams on both sides. Both branches are encoding-consistent with the mint.
  • bindOwnerState / ownerStateKey composition matches (ownerStateKey ? JSON.stringify([ownerStateKey, identityKey]) : identityKey), and the level-0 empty-branch fallback matches the mint's [{ identityKey: "", ... treePosition: 0 }] candidate. Slot descriptor fields (activeRouteGraphId: null, interceptionTargetRouteGraphId: null, state) match deriveSlotIdentity called with includeInterceptionTarget=false on a non-intercepted binding.

The layout path's identity branch (currentIdentity present) is the primary path and is airtight; the no-identity fallback is only reachable before identities are minted onto mounted elements (first navigation after a full page load).

Non-blocking notes

  1. getOmittedBfcacheSegmentIds depth cap (depth > 100) is safe. A rendered segment below depth 100 would be misclassified as omitted, but the retention check then re-derives its target identity: if it matches current, it retains (safe — just waits for the authoritative payload); if not, it commits. A false "omitted" is conservative, not a correctness bug.
  2. Implicit invariant worth a one-line comment. The fallback and identity paths both rely on resolveAppPagePatternStateKey(layout.patternParts, ...) producing the same key sequence as the mint's resolveAppPageRouteStateKey(routeSegments.slice(0, treePosition), ...). This holds only because route groups are excluded from both. A comment would guard against a future patternParts-derivation change silently desyncing the retention check from minted identities.

Refactor

extractRawParamsForMatchedRoute → shared extractRawRoutePatternParams and the canonicalizeAppPageParams relocation to app-page-segment-state.ts are behavior-preserving (Object.create(null), identical catch-all/decode handling, no circular import).

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2940.

Summary

I reviewed PR #2940 (fix(app-router): retain shared layouts across loading shells), independently tracing the new identity-derivation logic against the authoritative mint path and resolving the disputed encoded-param "fallback bug" against the real runtime data flow. Ran the targeted suites (all green) and vp check (clean).

Verdict: looks correct, no blocking issues.

The key dispute — resolved

The earlier bonk review claimed a narrow bug: the no-identity fallback compares a decoded currentParams against a canonical targetRouteParams, resetting shared-layout state for encoded params. The author disputed this.

I traced the full param encoding lifecycle end-to-end and the author is correct — it is not a bug:

  • App Page params are canonicalized at the matcher (app-rsc-route-matching.ts:185); only Route Handlers decode.
  • navigationParams derives from those canonical params and is serialized canonically to X-Vinext-Params — the encodeURIComponent at app-page-response.ts:309 wraps the JSON transport string, not the per-value contents.
  • The browser reverses only that wrapper (JSON.parse(decodeURIComponent(...))) and stores values verbatim.

So both sides of the fallback comparison are canonical → /projects/a%2Fb/activity compares equal → correctly retains. The proposed decode/re-encode fix would actually be wrong (double-encoding is not recoverable after decode).

Other things verified

  • Identity parity (highest risk): resolveTargetBfcacheSegmentIdentity re-derives target identities matching the authoritative graph-mode mint — confirmed graph-id equivalence (graphIds.slots[slotKey] === binding.slotId, ownerLayoutId is already a graph id), slot-param encoding parity (both use decoded matchRoutePattern in the override branch), and ownerStateKey/bindOwnerState composition.
  • Depth-100 cap in getOmittedBfcacheSegmentIds is conservative-safe, not a correctness bug.
  • Refactor (extractRawRoutePatternParams, canonicalizeAppPageParams relocation) is behavior-preserving.

I raised one genuinely useful non-blocking note: add a one-line comment documenting the implicit patternParts vs route-prefix state-key equivalence invariant.

github run

@james-elicx
james-elicx changed the base branch from repro/app-router-shared-layout-identity to main August 17, 2026 10:27
@james-elicx
james-elicx merged commit c296ad3 into main Aug 17, 2026
81 of 82 checks passed
@james-elicx
james-elicx deleted the codex/fix-retain-shared-layout branch August 17, 2026 10:28
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