Skip to content

feat: two-phase navigation commit with visited response cache#643

Open
NathanDrake2406 wants to merge 15 commits intocloudflare:mainfrom
NathanDrake2406:fix/app-router-navigation-cache-replay
Open

feat: two-phase navigation commit with visited response cache#643
NathanDrake2406 wants to merge 15 commits intocloudflare:mainfrom
NathanDrake2406:fix/app-router-navigation-cache-replay

Conversation

@NathanDrake2406
Copy link
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 22, 2026

Ref #639

Summary

Redesigns the App Router client-side navigation system to eliminate URL/hook desync flashes and enable instant back/forward navigation.

Core architecture changes

  • Two-phase navigation commit — URL and history updates are deferred to useLayoutEffect after React commits the new tree, preventing usePathname()/useSearchParams()/useParams() from returning stale values during transitions
  • Navigation render context — A React context (ClientNavigationRenderSnapshot) provides the pending URL and params to hooks during the render phase, before history is committed. This means components see consistent navigation state throughout the entire render cycle
  • RSC response buffering — Cold navigation responses are fully buffered via snapshotRscResponse before passing to createFromFetch, ensuring the flight parser processes all rows in a single synchronous pass (prevents partial tree commits where list content updates before heading hooks catch up)
  • Visited response cache — RSC responses are snapshotted to ArrayBuffer after each navigation and cached (30s TTL, 50 entry cap). Back/forward navigations replay from cache instantly. router.refresh() bypasses the cache
  • Navigation snapshot activation counter — Hooks only prefer the render snapshot context during active transitions (tracked via ref-counted _navigationSnapshotActiveCount). After commit, hooks fall through to useSyncExternalStore so user pushState/replaceState calls are immediately reflected
  • Non-blocking prefetch consumptionconsumePrefetchResponse only returns settled responses synchronously, matching Next.js's segment cache behavior. Pending prefetches never block navigation (fixes Firefox nav bar hang)
  • History notification suppressionpushHistoryStateWithoutNotify/replaceHistoryStateWithoutNotify wrap the original (unpatched) history methods to update the URL without triggering useSyncExternalStore subscribers prematurely
  • Server action redirect fix — Immediate history.pushState before fire-and-forget __VINEXT_RSC_NAVIGATE__ with .catch cleanup, avoiding deadlock between the form's outer useTransition and renderNavigationPayload's inner startTransition

Unified navigation surface

  • navigateClientSide() is now the single entry point for all client-side navigation (Link, Form, router.push/replace)
  • Link and Form no longer manipulate history directly — they delegate to navigateClientSide which coordinates with __VINEXT_RSC_NAVIGATE__
  • Hash-only changes, scroll restoration, and animation suppression are handled centrally in navigateImpl

Next.js parity notes

This brings vinext closer to Next.js for common navigation cases (forward nav, back/forward, prefetch reuse, shallow routing):

  • Page-level caching vs Next.js's segment-level caching — vinext caches and replays the entire RSC response per URL. Simpler but coarser; a back/forward replays the whole page tree, not just changed segments
  • Two-phase commit is a straightforward stage → render → commit-in-useLayoutEffect flow, vs Next.js's 2000+ line router reducer state machine
  • Snapshot/restore via ArrayBuffer is minimal compared to Next.js's multi-tier Flight cache

Known limitation: CSS animation replay on navigation

vinext replaces the entire page tree on navigation, causing all CSS animations (fade-in, slide-in) to replay on freshly-mounted elements. Next.js avoids this architecturally via segment-level caching — only changed segments re-mount, so unchanged layouts/templates keep their DOM elements. This is a deeper architectural issue tracked separately from this PR.

Test plan

  • pnpm test tests/shims.test.ts tests/link.test.ts tests/app-router.test.ts — all pass (1074 tests)
  • pnpm run test:e2e — all pass (501 tests, 0 failures)
  • pnpm run check — zero lint, type, or formatting errors
  • Shallow routing: pushState/replaceState updates reflected in usePathname/useSearchParams
  • Server action redirect() navigates correctly
  • Cold Link navigation keeps URL state aligned with committed tree
  • Animation suppression on mount-time row animations

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 22, 2026

Open in StackBlitz

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

commit: efe7f40

@NathanDrake2406 NathanDrake2406 changed the title Fix app router navigation replay and refresh caching feat: two-phase navigation commit with visited response cache Mar 22, 2026
@NathanDrake2406 NathanDrake2406 marked this pull request as draft March 22, 2026 05:49
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review March 22, 2026 15:49
@NathanDrake2406
Copy link
Contributor Author

@james-elicx should I split this out?

@NathanDrake2406 NathanDrake2406 force-pushed the fix/app-router-navigation-cache-replay branch from 539a033 to 5d02261 Compare March 23, 2026 06:08
@james-elicx
Copy link
Collaborator

@james-elicx should I split this out?

Smaller PRs are generally preferred. These bigger ones start to get hard to review - I like to go through diffs myself as well as the bot...

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
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: Two-phase navigation commit with visited response cache

This is a substantial redesign of the client-side navigation system. The core architecture — deferring URL/history updates until useLayoutEffect after React commits — is well-motivated and the right approach for preventing hook desync flashes. The visited response cache, RSC response buffering, and non-blocking prefetch consumption are all solid additions.

That said, there are several correctness concerns, a duplicated scroll restoration mechanism, and the PR size makes it harder to verify edge cases. I agree with the maintainer's suggestion to split this into smaller PRs.

High-level observations

  1. _navigationSnapshotActiveCount is a module-level variable, not on the global state object. The ClientNavigationState was carefully moved to a Symbol.for-keyed global to survive multiple module instances (Vite can load navigation.ts through different resolved IDs). But _navigationSnapshotActiveCount is a plain let at module scope, so if Vite loads two copies of this module, they'll have independent counters. One copy's activateNavigationSnapshot() won't be balanced by the other copy's commitClientNavigationState() decrement, causing hooks to permanently return stale snapshot values.

  2. Duplicate scroll restoration for popstate. navigation.ts has restoreScrollPosition (lines 833-861, called from its own popstate listener at line 1215 for Pages Router). app-browser-entry.ts adds restorePopstateScrollPosition (lines 334-345, called at line 619 for App Router). Both exist for different code paths, but the implementations diverge — the browser-entry version is simpler and doesn't wait for __VINEXT_RSC_PENDING__. The navigation.ts version waits but defers via microtask. This seems intentional since the browser-entry now handles scroll restoration after renderNavigationPayload resolves, but having two near-identical functions doing the same thing in two files is a maintenance hazard.

  3. No cancellation of superseded navigations. If a user rapidly clicks Link A then Link B, both navigations run to completion. The _navigationSnapshotActiveCount counter handles overlapping activations, but the first navigation's renderNavigationPayload still runs updateBrowserTree and its commit effect still pushes to history. Only the later renderId resolves last, but the earlier one may also commit an intermediate URL to history. Consider adding an abort signal or navigation sequence check.

  4. restoreRscResponse uses snapshot.body.slice(0) but the original ArrayBuffer is shared. ArrayBuffer.prototype.slice creates a copy, which is correct for allowing re-reads. However, the visited response cache stores the original ArrayBuffer — if snapshotRscResponse is called and the response body is consumed, the stored buffer is fine. But restoreRscResponse is called multiple times for cached entries, and each call creates a new copy. This is fine for correctness but worth noting for memory — 50 cached full-page RSC responses could be significant.

  5. storePrefetchResponse is now async (breaking change for callers). The function signature changed from void to Promise<void>. The call site in link.tsx was updated to use prefetchRscResponse instead, and the old storePrefetchResponse is only used from tests. But if any external code imported it, this would be a silent behavioral change.

See inline comments for specific issues.

// the snapshot while it's active.
// ---------------------------------------------------------------------------

let _navigationSnapshotActiveCount = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

This counter should live on the ClientNavigationState global object (keyed by Symbol.for), not as a module-level let. The rest of the client navigation state was moved to a global symbol specifically because Vite can load navigation.ts through multiple resolved module IDs (the virtual module resolution quirks documented in AGENTS.md). If two module instances exist, activateNavigationSnapshot() increments one copy's counter while commitClientNavigationState() decrements the other's, leaving hooks permanently stuck on the stale render snapshot.

Suggested change
let _navigationSnapshotActiveCount = 0;
// Stored on ClientNavigationState to survive multiple module instances.

return !!value && typeof value === "object" && "root" in value;
}

function isThenable<T>(value: T | Promise<T>): value is Promise<T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

isThenable doesn't account for the case where value could be a thenable that isn't a native Promise (e.g., React's lazy wrappers, or createFromFetch return values). The "then" in value check would match any object with a then property (including objects with then: undefined). Consider checking that then is actually a function:

Suggested change
function isThenable<T>(value: T | Promise<T>): value is Promise<T> {
function isThenable<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && typeof (value as any).then === "function";
}

useLayoutEffect(() => {
runPrePaintNavigationEffect(renderId);

// Resolve the navigation commit promise after the browser paints.
Copy link
Contributor

Choose a reason for hiding this comment

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

The comment says "requestAnimationFrame fires after the next paint" — but requestAnimationFrame fires before the next paint, not after. It's useLayoutEffect that fires before paint. The rAF callback scheduled from useLayoutEffect will fire before the following frame's paint, which means the navigation commit promise resolves ~16ms later than the actual DOM commit. If the intent is to resolve after paint, requestAnimationFrame is correct but the comment is misleading. If the intent is to resolve right after commit, you could resolve synchronously in useLayoutEffect.

navigationSnapshot: initialNavigationSnapshot,
});

useLayoutEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Setting a module-level variable from inside useLayoutEffect is a React anti-pattern — it creates a dependency on render timing and won't survive Strict Mode's double-invocation in development (the cleanup runs and nulls out setBrowserTreeState, then the second effect sets it again, which happens to work but is fragile). Consider using a useRef and exposing it through a stable callback ref, or storing the setter in the global ClientNavigationState object.

// so React keeps the old UI visible during the transition. For cross-route
// navigations (different pathname), use synchronous updates — React's
// startTransition hangs in Firefox when replacing the entire tree.
const isSameRoute = url.pathname === window.location.pathname;
Copy link
Contributor

Choose a reason for hiding this comment

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

The isSameRoute heuristic compares url.pathname === window.location.pathname, but at this point window.location.pathname hasn't been updated yet (the two-phase commit defers the history update). For cached routes served from the visited response cache (line 521-536), this comparison uses the old pathname from before navigation started. This means navigating A→B→A would treat the second A→B as isSameRoute=false on the first click but could incorrectly compute isSameRoute=true on a rapid second click if the first commit hasn't flushed yet.

Is this intentional? The comment says it controls startTransition usage, so a wrong value would affect UX (sync vs async update) but not correctness.

updateBrowserTree(
rscPayload,
createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
nextNavigationRenderId,
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: nextNavigationRenderId (without ++) is used here instead of ++nextNavigationRenderId. This means the HMR update reuses the same renderId as the previous navigation. If a pending navigation commit for that renderId hasn't resolved yet, resolveCommittedNavigations would resolve it prematurely when the HMR update's NavigationCommitSignal mounts.

Suggested change
nextNavigationRenderId,
createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
++nextNavigationRenderId,
false,

if (!state) return;

const json = JSON.stringify(params);
if (json !== state.clientParamsJson || json !== state.pendingClientParamsJson) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The condition json !== state.clientParamsJson || json !== state.pendingClientParamsJson means this will set hasPendingNavigationUpdate = true even when the pending params haven't changed (because state.pendingClientParamsJson starts as null, and json !== null is always true). After the first call, subsequent calls with the same params will still set the flag because the || short-circuits on the first condition being false but the second being true (if state.pendingClientParamsJson was already set to json, the second clause is false, so it correctly skips — but only after the first call).

The real issue: on the very first replaceClientParamsWithoutNotify call, even if json === state.clientParamsJson, the function sets hasPendingNavigationUpdate = true because json !== null (since state.pendingClientParamsJson is null). Consider:

Suggested change
if (json !== state.clientParamsJson || json !== state.pendingClientParamsJson) {
if (json !== state.clientParamsJson && json !== state.pendingClientParamsJson) {

Using && would only set the pending update when the params actually differ from both the committed and the already-pending value.

}
}

function getVisitedResponse(
Copy link
Contributor

Choose a reason for hiding this comment

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

The getVisitedResponse function returns the cached entry for "traverse" navigations regardless of TTL (line 183-184). This means back/forward navigations never expire from the visited cache — a user could go back to a page hours later and still see the stale cached version. While the PR description mentions this is intentional for instant back/forward, it differs from Next.js which respects a 30s client-side cache TTL even for traversals (the staticPrefetchBailout behavior). The clearClientNavigationCaches on server actions helps, but navigations that don't trigger server actions won't invalidate.

Is there a plan for a separate traversal TTL or an LRU eviction for traversal entries?

}

function registerServerActionCallback(): void {
setServerCallback(async (id, args) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

clearClientNavigationCaches() at the top of every server action callback is aggressive — it clears all prefetch and visited caches before the action even runs. If the action fails or doesn't revalidate anything, the caches are already gone. Consider clearing only after a successful action that triggers revalidation, or at least only clearing the visited cache (since prefetch data is still valid until revalidation).

}

export function restoreRscResponse(snapshot: CachedRscResponse): Response {
return new Response(snapshot.body.slice(0), {
Copy link
Contributor

Choose a reason for hiding this comment

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

snapshot.body.slice(0) creates a full copy of the ArrayBuffer on every restoreRscResponse call. For the visited cache (50 entries, each potentially hundreds of KB for complex pages), each navigation that hits cache creates a copy. The slice is necessary because Response consumes the buffer, but consider whether a ReadableStream wrapping a Uint8Array view could avoid the copy:

// Zero-copy alternative:
const body = new ReadableStream({
  start(controller) {
    controller.enqueue(new Uint8Array(snapshot.body));
    controller.close();
  },
});
return new Response(body, { ... });

This avoids the allocation since Uint8Array(buffer) creates a view (no copy) and the Response constructor accepts ReadableStream.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 23, 2026

Review posted successfully. Here's a summary of the key findings:

Architecture — The two-phase navigation commit (stage → render → commit in useLayoutEffect) is a sound approach for preventing URL/hook desync flashes. The visited response cache, RSC response buffering, and non-blocking prefetch consumption are well-designed additions.

Key issues found:

  1. _navigationSnapshotActiveCount lives at module scope instead of on the Symbol.for-keyed global state. This breaks if Vite loads two copies of navigation.ts — activate/deactivate calls from different module instances won't be balanced.

  2. isThenable check is too loose"then" in value matches objects with then: undefined. Should check typeof .then === "function".

  3. Module-level setBrowserTreeState from useLayoutEffect is fragile under React Strict Mode's double-invocation.

  4. HMR update reuses the same renderId (no ++), which could prematurely resolve pending navigation commits.

  5. replaceClientParamsWithoutNotify condition uses || instead of &&, causing unnecessary hasPendingNavigationUpdate flag sets when params haven't changed.

  6. Traverse navigations bypass TTL entirely — back/forward to a page hours later still serves the stale cache.

  7. Aggressive cache clearing on every server action — clears all caches before the action runs, even if it fails.

  8. restoreRscResponse copies the full ArrayBuffer on every call — a zero-copy ReadableStream approach could reduce memory pressure.

  9. No cancellation of superseded navigations — rapid clicks push multiple URLs to history.

  10. Duplicate scroll restoration logic across navigation.ts and app-browser-entry.ts — maintenance hazard.

  11. Misleading comment about requestAnimationFrame firing "after" paint (it fires before).

I also agree with the maintainer's suggestion to split this into smaller PRs given the scope (~1950 additions across 19 files).

github run

@NathanDrake2406 NathanDrake2406 force-pushed the fix/app-router-navigation-cache-replay branch from 3eb7afe to 33caea2 Compare March 23, 2026 14:31
- Fix broken indentation and missing fallback in server action redirect handler
- Replace useEffect with useLayoutEffect in NavigationCommitSignal
- Eliminate `as` type assertions via createFromFetch<ReactNode> generics
- Replace querySelectorAll("*") + getComputedStyle with document.getAnimations()
- Cache getSearchParamsSnapshot fallback to prevent potential infinite re-renders
- Add module-level fallback for setClientParams in test/SSR environments
- Fix History.prototype reference crash in test environments
- Update global.d.ts prefetch cache type to match PrefetchCacheEntry
- Update prefetch-cache tests for async storePrefetchResponse and CachedRscResponse
Add Playwright tests for navigation flash regressions covering
link-sync, param-sync, and query-sync scenarios with corresponding
test fixture pages.
Form component now delegates to navigateClientSide instead of calling
pushState + navigate separately. Update test assertions to expect the
new 4-arg __VINEXT_RSC_NAVIGATE__ signature and add missing window
mock properties (pathname, search, hash, scrollX/Y).
Restore useEffect in NavigationCommitSignal, restore querySelectorAll-based
animation suppression, and restore separate effect hooks.
…ts, and Firefox nav hang

- Buffer full RSC response before createFromFetch to prevent flight parser
  microtask interleaving that causes partial tree commits on cold navigations
- Await createFromFetch to fully resolve the tree before rendering
- Add navigation snapshot activation counter so hooks only prefer the
  render snapshot context during active transitions, fixing pushState/
  replaceState reactivity (shallow routing)
- Restore immediate history.pushState for server action redirects and
  use fire-and-forget navigate to avoid useTransition/startTransition deadlock
- Always call commitClientNavigationState after navigation commit
- Don't block navigation on pending prefetch responses, matching Next.js
  segment cache behavior (fixes Firefox nav bar hang)
- Always run animation suppression for all navigations, not just cold fetches

Ref cloudflare#639
suppressFreshNavigationAnimations only caught animations with
fill-mode: both/backwards (opacity-based check). Real-world CSS uses
the default fill-mode (none), making the function a no-op. Next.js
has zero animation suppression code — they avoid the problem via
segment-level caching that only re-mounts changed segments.

Remove the function, its composition helper, and E2E assertions that
tested suppression behavior. The animation replay issue is tracked
as a separate architectural concern (segment-level caching).
…ignal

Consolidate the two layout/effect hooks into one useLayoutEffect.
The requestAnimationFrame fires after paint regardless of where it's
scheduled from, so useEffect was unnecessary.
React's startTransition hangs indefinitely in Firefox when replacing
the entire component tree (cross-route navigation). The transition
never commits, leaving the old page visible with no way to recover.

Use startTransition only for same-route navigations (searchParam
changes) where it keeps the old UI visible during loading. For
cross-route navigations (different pathname), use synchronous state
updates so the new page renders immediately.

Also removes debug logging from the investigation.
- Increment nextNavigationRenderId for server action renders to avoid
  stale renderId collisions with navigation commits
- Deactivate snapshot counter on navigation failure to prevent hooks
  from permanently returning stale values
- Remove navigateClientSide wrapper (was 1:1 pass-through of navigateImpl)
- Remove dead fallback URL update block in navigateClientSide that could
  never fire (createNavigationCommitEffect always pushes history)
- Rename animation test to match what it actually asserts
- Move _navigationSnapshotActiveCount to ClientNavigationState global
  (Symbol.for key) so it survives multiple Vite module instances
- Fix isThenable to check typeof then === "function" (not just "then" in value)
- Fix replaceClientParamsWithoutNotify: use && not || to avoid setting
  hasPendingNavigationUpdate when params haven't actually changed
- Increment renderId for HMR updates to avoid stale renderId collisions
- Zero-copy restoreRscResponse using Uint8Array view instead of
  ArrayBuffer.slice(0) — avoids allocating a full copy per cache hit
- Fix rAF comment: requestAnimationFrame fires before the next paint,
  not after
- Add 5-minute max staleness for traverse (back/forward) cache entries
  instead of serving indefinitely stale data
- Move cache invalidation after successful server action (not before),
  so failed actions don't unnecessarily clear valid caches
- Add navigation cancellation via activeNavigationId — superseded
  navigations bail out after fetch to avoid pushing stale URLs
- Remove useLayoutEffect cleanup for setBrowserTreeState (BrowserRoot
  is a singleton, cleanup created a Strict Mode null window)
- Document scroll restoration split between browser entry (App Router)
  and navigation.ts (Pages Router)
…id navigation

- Drain all pre-paint effects up to the committed renderId in
  NavigationCommitSignal, so superseded startTransition renders still
  get their counter decremented
- Only run history push/replace for the winning navigation — superseded
  ones just balance the snapshot counter without creating phantom entries
- Add second navId staleness check after snapshotRscResponse await
- Disable browser native scroll restoration (history.scrollRestoration)
  to prevent scroll jank on back/forward
- Remove dead reactRoot variable and unused Root import
- Replace banned 'as' cast in isThenable with 'in' narrowing
- Fix stale JSDoc and misleading comments
- Remove redundant null check in commitClientNavigationState
@NathanDrake2406 NathanDrake2406 force-pushed the fix/app-router-navigation-cache-replay branch from 47ed0ac to efe7f40 Compare March 24, 2026 15:13
@NathanDrake2406
Copy link
Contributor Author

@james-elicx should be good now

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