Skip to content

fix(router): replace stale optimistic layouts across dynamic params#2609

Merged
james-elicx merged 7 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-optimistic-navigation-reuse-base
Jul 21, 2026
Merged

fix(router): replace stale optimistic layouts across dynamic params#2609
james-elicx merged 7 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-optimistic-navigation-reuse-base

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Prevent stale server-rendered dynamic-layout output from surviving an optimistic-to-authoritative navigation handoff
Core change Prepare every payload in one navigation against the immutable router state captured when that navigation starts
Commit authority Continue checking the live router state before any payload becomes visible
Expected behavior Cross-param layouts receive fresh output and identity; shared roots and same-param sibling layouts retain client state
Scope Browser navigation preparation, controller input typing, and focused BFCache identity coverage

Closes #2597.

Why

A single navigation can render a detached optimistic shell before its authoritative RSC payload arrives. Both payloads must derive layout reuse from the same pre-navigation state. Previously, commit preparation sampled whichever router state was visible at that moment. Once the detached shell committed, the authoritative response could treat that shell as its reuse base and preserve stale dynamic-layout output from another param.

Area Principle / invariant What this PR changes
Navigation preparation Payloads from one navigation share one historical identity base navigateRsc() captures the initiation state once, before redirects, prefetch consumption, or detached commits
Commit approval Only current work may mutate visible state Approval still reads the live router state and rejects superseded navigation IDs or unrelated visible commits
Layout identity Concrete dynamic-param changes require fresh identity Existing identity comparison replaces cross-param or newly introduced optimistic layouts while preserving proven shared layouts
Ownership Decision inputs should cross boundaries explicitly The private payload wrapper now uses a typed options object and passes the initiation state into controller preparation

This follows the navigation architecture in #726: deterministic preparation receives explicit inputs, while the visible commit gate owns lifecycle authority. The trace work in #2598 helped isolate this boundary, but this fix does not depend on diagnostics.

What changed

Scenario Before After
Dashboard to optimistic B to authoritative B Authoritative preparation could reuse stale A output installed by the detached shell Authoritative preparation compares against the dashboard initiation state and installs B
Same-param sibling navigation Shared dynamic layout state could be disturbed by a broad replacement fix Existing layout object and BFCache identity remain stable
Dynamic-layout-owned slot A slot introduced by the detached owner could survive the authoritative payload The authoritative owner and slot receive fresh identities together
Redirect or visited-response handoff A later payload could prepare from state committed by an earlier hop Redirected, cached, detached, and live payloads share the initiation state
Cancellation and supersession Live state guarded visible commits Unchanged

The browser regression holds the B RSC response long enough to inspect the detached shell, then releases it and verifies that server props, pathname, params, client state, and BFCache identity all agree on B.

Validation
  • vp check on all touched source, test, and fixture files
  • vp test run tests/app-browser-entry.test.ts (227 tests)
  • PLAYWRIGHT_PROJECT=app-router vp exec playwright test tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts --workers=1 (13 tests)
  • Server-action invalidation race repeated three times after replacing response-timing with a BFCache-epoch commit assertion
  • vp run vinext#build
  • git diff --check
  • Repository pre-commit staged checks, full check, staged unit and integration tests, and knip
Risk and compatibility
  • No public API, configuration, wire-format, or build-output change.
  • The runtime change only affects the state used to prepare navigation identities.
  • The live commit gate still owns cancellation, supersession, root-boundary checks, and visible commit approval.
  • The fix reuses the existing layout and slot identity rules instead of adding a parallel cache lookup or fallback policy.
  • The new loading boundary and server-derived markers exist only in the app-router test fixture.
Non-goals
  • Add segment-cache entries or a new segment identity model.
  • Change NavigationPlanner decisions or scan caches during commit preparation.
  • Introduce a global reuseCurrentBfcacheIds fallback.
  • Couple correctness to the opt-in diagnostics from feat(router): expose opt-in navigation diagnostics #2598.

References

Reference Why it matters
Issue #2597 Reproduction and expected cross-param behavior
Navigation architecture #726 Defines the separation between deterministic decisions and visible commit authority
Navigation diagnostics #2598 Trace evidence that localized the stale state to the optimistic-to-authoritative handoff
Committed payload reuse #2251 Prior reuse path implicated by the reported sequence
Next.js optimistic navigation preparation Upstream models the optimistic tree as a provisional result reconciled by the navigation response
Next.js segment identity rules Distinguishes fresh dynamic-param identity from search-only reuse
Next.js BFCache identity tests Covers sibling layout preservation and cross-group reset semantics

A detached optimistic shell can commit stale dynamic-layout output before the authoritative payload resolves. Preparing the latter from live router state then makes the shell appear current, so stale server props and BFCache identity survive a cross-param navigation.

All payloads in one navigation must derive reuse identity from the same initiation state. Capture that state once and pass it through commit preparation, while live router state remains the authority for cancellation and commit approval.

Add composition coverage for cross-param replacement, same-param preservation, and layout-owned slots, plus a deterministic browser regression for the prefetched-shell handoff.
@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 03905bc

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 03905bc against base 060de14 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 130.5 KB 130.6 KB ⚫ +0.1%
Client entry size (gzip) vinext 118.0 KB 118.1 KB ⚫ +0.1%
Dev server cold start vinext 2.84 s 2.91 s 🔴 +2.3%
Production build time vinext 3.21 s 3.20 s ⚫ -0.2%
RSC entry closure size (gzip) vinext 101.8 KB 101.7 KB ⚫ -0.0%
Server bundle size (gzip) vinext 169.0 KB 169.0 KB ⚫ -0.0%

View detailed results and traces

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

@NathanDrake2406 NathanDrake2406 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

navigationInitiationState is a correctness input for lifecycle-spanning navigations, but it remains optional and silently falls back to getBrowserRouterState(). That leaves future callers able to recreate the same temporal bug while still compiling.

Please strengthen the API so initiation-anchored payload preparation requires this state. At minimum, make it required in the private renderNavigationPayload wrapper used by navigateRsc(). If the lower-level controller still needs current-state preparation for isolated operations, expose that as an explicit separate path rather than an implicit fallback.

Navigation payload callers could omit the initiation state and silently prepare from live router state. A future caller could therefore compile while reintroducing the optimistic-to-authoritative identity bug.

Require the state at both browser-entry and controller boundaries and remove the live-state fallback. Isolated controller tests now choose current-state preparation through an explicitly named test helper.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Addressed in 63453313d. navigationInitiationState is now required at both the browser-entry wrapper and controller boundary, and payload preparation no longer falls back to getBrowserRouterState(). Direct controller tests that intentionally prepare from current state now do so through the explicitly named test-only renderCurrentStateNavigationPayload() helper.

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review July 13, 2026 08:44
NathanDrake2406 and others added 3 commits July 13, 2026 18:48
Navigation payload regressions advanced a fixed number of microtasks before reading router state. That coupled the tests to the controller's current async scheduling depth.\n\nExpose a one-shot visible-commit dispatch waiter from the controller harness and await that explicit boundary before assertions.
…gationCommit

createPendingNavigationCommit's currentState param has no note on what
it should be. Navigation callers now pass the frozen navigation-initiation
state (per the previous two commits), while the HMR caller still passes
live state, and nothing marks that split as deliberate.

A future navigation call site that passes live state instead of the
initiation state would silently reintroduce the stale cross-param reuse
bug this branch fixes, with no type error to catch it.

Document the invariant on the field so the split reads as intentional.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 17da04d4f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@quantizor

Copy link
Copy Markdown

👏 thanks for all these improvements, I've been loving vinext

@james-elicx
james-elicx merged commit 2824b76 into cloudflare:main Jul 21, 2026
98 of 100 checks passed
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

👏 thanks for all these improvements, I've been loving vinext

Thanks bro! But it's all this gigachad -> @james-elicx

NathanDrake2406 added a commit to NathanDrake2406/vinext that referenced this pull request Jul 22, 2026
…ream merge

Upstream now owns both behaviors this branch previously patched:

- createPendingNavigationCommit baselines on the navigation initiation
  state (cloudflare#2609), which is what lets an authoritative payload replace a
  stale cross-param optimistic layout. The payload-ready live re-read
  re-introduced exactly that staleness, so the currentStateTiming /
  getCurrentStateAfterElementsReady plumbing is removed.
- Loading-shell prefetch responses intentionally stream generated
  metadata so rewrites can reuse it without a second request (cloudflare#2455,
  cloudflare#2318). Stripping resolvedMetadata / streamingMetadata from shell
  renders broke that protocol; the element-builder now matches upstream.
  Keeping shell payloads out of navigation consumption remains handled
  client-side via isPrefetchCacheEntryConsumableForNavigation.

Tests asserting the old behaviors are removed or aligned with the
upstream contract (authoritative commits declare navigationCommitKind).
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.

App Router: soft navigation between different dynamic params reuses the committed shared-layout subtree without fetching the new RSC payload

3 participants