feat: two-phase navigation commit with visited response cache#643
feat: two-phase navigation commit with visited response cache#643NathanDrake2406 wants to merge 15 commits intocloudflare:mainfrom
Conversation
commit: |
|
@james-elicx should I split this out? |
539a033 to
5d02261
Compare
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... |
|
/bigbonk review |
There was a problem hiding this comment.
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
-
_navigationSnapshotActiveCountis a module-level variable, not on the global state object. TheClientNavigationStatewas carefully moved to aSymbol.for-keyed global to survive multiple module instances (Vite can loadnavigation.tsthrough different resolved IDs). But_navigationSnapshotActiveCountis a plainletat module scope, so if Vite loads two copies of this module, they'll have independent counters. One copy'sactivateNavigationSnapshot()won't be balanced by the other copy'scommitClientNavigationState()decrement, causing hooks to permanently return stale snapshot values. -
Duplicate scroll restoration for popstate.
navigation.tshasrestoreScrollPosition(lines 833-861, called from its own popstate listener at line 1215 for Pages Router).app-browser-entry.tsaddsrestorePopstateScrollPosition(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 afterrenderNavigationPayloadresolves, but having two near-identical functions doing the same thing in two files is a maintenance hazard. -
No cancellation of superseded navigations. If a user rapidly clicks Link A then Link B, both navigations run to completion. The
_navigationSnapshotActiveCountcounter handles overlapping activations, but the first navigation'srenderNavigationPayloadstill runsupdateBrowserTreeand its commit effect still pushes to history. Only the laterrenderIdresolves last, but the earlier one may also commit an intermediate URL to history. Consider adding an abort signal or navigation sequence check. -
restoreRscResponseusessnapshot.body.slice(0)but the originalArrayBufferis shared.ArrayBuffer.prototype.slicecreates a copy, which is correct for allowing re-reads. However, the visited response cache stores the originalArrayBuffer— ifsnapshotRscResponseis called and the response body is consumed, the stored buffer is fine. ButrestoreRscResponseis 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. -
storePrefetchResponseis nowasync(breaking change for callers). The function signature changed fromvoidtoPromise<void>. The call site inlink.tsxwas updated to useprefetchRscResponseinstead, and the oldstorePrefetchResponseis 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; |
There was a problem hiding this comment.
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.
| 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> { |
There was a problem hiding this comment.
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:
| 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. |
There was a problem hiding this comment.
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(() => { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
| nextNavigationRenderId, | |
| createClientNavigationRenderSnapshot(window.location.href, latestClientParams), | |
| ++nextNavigationRenderId, | |
| false, |
| if (!state) return; | ||
|
|
||
| const json = JSON.stringify(params); | ||
| if (json !== state.clientParamsJson || json !== state.pendingClientParamsJson) { |
There was a problem hiding this comment.
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:
| 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( |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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), { |
There was a problem hiding this comment.
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.
|
Review posted successfully. Here's a summary of the key findings: Architecture — The two-phase navigation commit (stage → render → commit in Key issues found:
I also agree with the maintainer's suggestion to split this into smaller PRs given the scope (~1950 additions across 19 files). |
3eb7afe to
33caea2
Compare
- 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
47ed0ac to
efe7f40
Compare
|
@james-elicx should be good now |
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
useLayoutEffectafter React commits the new tree, preventingusePathname()/useSearchParams()/useParams()from returning stale values during transitionsClientNavigationRenderSnapshot) 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 cyclesnapshotRscResponsebefore passing tocreateFromFetch, 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)ArrayBufferafter each navigation and cached (30s TTL, 50 entry cap). Back/forward navigations replay from cache instantly.router.refresh()bypasses the cache_navigationSnapshotActiveCount). After commit, hooks fall through touseSyncExternalStoreso userpushState/replaceStatecalls are immediately reflectedconsumePrefetchResponseonly returns settled responses synchronously, matching Next.js's segment cache behavior. Pending prefetches never block navigation (fixes Firefox nav bar hang)pushHistoryStateWithoutNotify/replaceHistoryStateWithoutNotifywrap the original (unpatched) history methods to update the URL without triggeringuseSyncExternalStoresubscribers prematurelyhistory.pushStatebefore fire-and-forget__VINEXT_RSC_NAVIGATE__with.catchcleanup, avoiding deadlock between the form's outeruseTransitionandrenderNavigationPayload's innerstartTransitionUnified navigation surface
navigateClientSide()is now the single entry point for all client-side navigation (Link, Form,router.push/replace)navigateClientSidewhich coordinates with__VINEXT_RSC_NAVIGATE__navigateImplNext.js parity notes
This brings vinext closer to Next.js for common navigation cases (forward nav, back/forward, prefetch reuse, shallow routing):
useLayoutEffectflow, vs Next.js's 2000+ line router reducer state machineArrayBufferis minimal compared to Next.js's multi-tier Flight cacheKnown 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 errorspushState/replaceStateupdates reflected inusePathname/useSearchParamsredirect()navigates correctly