Skip to content

fix: cross-route client navigation hangs in Firefox (#652)#690

Open
Divkix wants to merge 4 commits intocloudflare:mainfrom
Divkix:fix/firefox-navigation-hang
Open

fix: cross-route client navigation hangs in Firefox (#652)#690
Divkix wants to merge 4 commits intocloudflare:mainfrom
Divkix:fix/firefox-navigation-hang

Conversation

@Divkix
Copy link
Contributor

@Divkix Divkix commented Mar 26, 2026

Summary

Fixes #652startTransition never commits in Firefox when the entire RSC component tree is replaced during cross-route navigation. The old page stays visible indefinitely in production builds on workerd.

Root cause: vinext replaces the full RSC tree on every cross-route navigation inside startTransition. The React transition scheduler cannot finalize this in Firefox. The previous flushSync approach also caused the Suspense double-flash from #639.

Fix: Two-phase navigation commit with same-route detection:

  • Same-route navigations (search param changes): use startTransition for smooth incremental updates
  • Cross-route navigations (different pathname): use synchronous updates that bypass the Firefox scheduler issue
  • URL/history commit deferred to useLayoutEffect so hooks see the pending URL during transitions

Key changes

  • navigation.ts: ClientNavigationState on Symbol.for global (survives multiple module instances), render snapshot context for hook consistency during transitions, RSC response snapshot/restore for visited cache, unified navigateClientSide() entry point, history suppression helpers
  • app-browser-entry.ts: BrowserRoot component with useState-managed tree, NavigationCommitSignal that defers URL commit to useLayoutEffect, visited response cache with LRU eviction (50 entries, 5min TTL), navigation ID counter for stale navigation bailout, manual scroll restoration
  • link.tsx: removed duplicated isHashOnlyChange/scrollToHash, delegates to navigateClientSide
  • form.tsx: App Router GET forms delegate to navigateClientSide
  • global.d.ts: extended __VINEXT_RSC_NAVIGATE__ signature with navigationKind and historyUpdateMode

Edge cases handled

  • Rapid clicking (navigation ID counter prevents stale commits)
  • Back/forward instant replay from visited response cache
  • Server action redirects
  • Hash-only changes (no RSC fetch)
  • External URLs (falls through to location.assign)
  • HMR (clears all navigation caches)
  • Multiple Vite module instances (Symbol.for global state)

Test plan

  • vp check passes (only pre-existing benchmark errors remain)
  • tests/shims.test.ts — 742 tests pass
  • tests/form.test.ts — updated assertions for new navigate signature, all pass
  • tests/prefetch-cache.test.ts — compatible with new PrefetchCacheEntry type, all pass
  • tests/link.test.ts — all pass
  • tests/routing.test.ts — all pass
  • tests/app-router.test.ts — all pass
  • tests/entry-templates.test.ts — all pass
  • New E2E tests in tests/e2e/app-router/navigation-regressions.spec.ts covering:
    • Cross-route navigation completes without hanging
    • Same-route navigation (search param change)
    • Back/forward navigation
    • Rapid same-route navigation settles correctly
    • Cross-route then same-route navigation
    • usePathname/useSearchParams/useParams hook sync
    • Provider page dynamic param navigation
    • Round-trip SPA state preservation
  • CI: full Vitest suite
  • CI: Playwright E2E (chromium, firefox, webkit)

Replace flushSync-based RSC tree rendering with a two-phase navigation
commit that uses startTransition for same-route navigations and
synchronous updates for cross-route navigations. This fixes the Firefox
hang where startTransition never commits when the entire component tree
is replaced, and also resolves the Suspense double-flash from cloudflare#639.

Key changes:
- navigation.ts: ClientNavigationState on Symbol.for global for module
  instance safety, render snapshot context for hook consistency during
  transitions, RSC response snapshot/restore for visited cache, unified
  navigateClientSide() entry point, history suppression helpers
- app-browser-entry.ts: BrowserRoot component with useState-managed tree,
  NavigationCommitSignal that defers URL commit to useLayoutEffect,
  visited response cache with LRU eviction, navigation ID counter for
  stale navigation bailout, manual scroll restoration
- link.tsx: remove duplicated helpers, delegate to navigateClientSide
- form.tsx: delegate App Router GET navigation to navigateClientSide

Closes cloudflare#652
Copilot AI review requested due to automatic review settings March 26, 2026 01:00
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 26, 2026

Open in StackBlitz

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

commit: e6978c0

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a Firefox-specific App Router client-navigation hang by introducing a two-phase navigation commit strategy that uses startTransition only for same-route updates and synchronous updates for cross-route navigations, while deferring URL/history publication until after the new tree commits.

Changes:

  • Reworked App Router client navigation flow (snapshot context for hooks, two-phase URL commit, visited response replay cache, and navigation staleness bailout).
  • Unified <Link> and <Form> App Router navigations through navigateClientSide() and updated prefetch behavior to snapshot RSC responses for replay.
  • Added/updated unit + Playwright regression coverage and new fixture routes to validate navigation behavior and hook consistency.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/form.test.ts Updates window stubs and assertions for the new __VINEXT_RSC_NAVIGATE__ signature and navigation flow.
tests/fixtures/app-basic/app/page.tsx Adds entry links to new navigation regression fixture routes.
tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx New fixture page for same-route (search param) navigation + Suspense behavior.
tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx Client controls validating usePathname/useSearchParams sync during transitions.
tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx New dynamic-param fixture route for cross-route navigation coverage.
tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx New dynamic segment fixture for param-change navigation behavior.
tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx Client controls validating useParams/usePathname updates on param changes.
tests/fixtures/app-basic/app/nav-flash/list/page.tsx New cross-route destination page used by regression tests.
tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx New fixture page for link-driven same-route navigation with Suspense.
tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx Client links validating hook snapshot consistency across navigation.
tests/e2e/app-router/navigation-regressions.spec.ts New Playwright regression suite for #652 and related hook/scroll/state behaviors.
packages/vinext/src/shims/navigation.ts Adds navigation global state, render snapshot context, two-phase navigation entry, and prefetch snapshot/consume helpers.
packages/vinext/src/shims/link.tsx Removes duplicated hash/scroll/nav logic and delegates App Router navigation + prefetch to shared helpers.
packages/vinext/src/shims/form.tsx Delegates App Router GET form navigation to navigateClientSide() to align history publishing with commits.
packages/vinext/src/server/app-browser-entry.ts Introduces BrowserRoot state tree management, commit signaling, visited response replay cache, and same-route detection for transition mode.
packages/vinext/src/global.d.ts Extends global typings for updated navigation signature and new prefetch cache entry shape.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +305 to +330
export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise<Response>): void {
const cache = getPrefetchCache();
const prefetched = getPrefetchedUrls();
const now = Date.now();

const entry: PrefetchCacheEntry = { timestamp: now };

entry.pending = fetchPromise
.then(async (response) => {
if (response.ok) {
entry.snapshot = await snapshotRscResponse(response);
} else {
prefetched.delete(rscUrl);
cache.delete(rscUrl);
}
})
.catch(() => {
prefetched.delete(rscUrl);
cache.delete(rscUrl);
})
.finally(() => {
entry.pending = undefined;
});

cache.set(rscUrl, entry);
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

prefetchRscResponse() adds entries to the shared prefetch cache without enforcing MAX_PREFETCH_CACHE_SIZE or sweeping expired entries. On link-heavy pages this can cause unbounded growth of both __VINEXT_RSC_PREFETCH_CACHE__ and __VINEXT_RSC_PREFETCHED_URLS__ (successful prefetches are never evicted unless navigated to). Consider reusing the same eviction/TTL sweep logic as storePrefetchResponse() before cache.set() (and deleting from prefetched when evicting).

Copilot uses AI. Check for mistakes.
Comment on lines +337 to +354
export function consumePrefetchResponse(rscUrl: string): CachedRscResponse | null {
const cache = getPrefetchCache();
const entry = cache.get(rscUrl);
if (!entry) return null;

// Don't consume pending entries — let the navigation fetch independently.
if (entry.pending) return null;

cache.delete(rscUrl);
getPrefetchedUrls().delete(rscUrl);

if (entry.snapshot) return entry.snapshot;

// Legacy: raw Response entries (from storePrefetchResponse)
// These can't be consumed synchronously as snapshots — skip them.
// The navigation code will re-fetch.
return null;
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

consumePrefetchResponse() does not check PREFETCH_CACHE_TTL before returning a cached snapshot. As written, a prefetched RSC payload can be consumed arbitrarily long after it was fetched, which can serve stale UI and defeats the TTL constant/comment above. Suggest: if Date.now() - entry.timestamp >= PREFETCH_CACHE_TTL, delete the entry + prefetched marker and return null.

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +165
function evictVisitedResponseCacheIfNeeded(): void {
while (visitedResponseCache.size >= MAX_VISITED_RESPONSE_CACHE_SIZE) {
const oldest = visitedResponseCache.keys().next().value;
if (oldest === undefined) {
return;
}
visitedResponseCache.delete(oldest);
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

evictVisitedResponseCacheIfNeeded() evicts the oldest inserted entry (Map insertion order), but cache hits in getVisitedResponse() don't refresh recency. This makes the behavior FIFO rather than true LRU as described in the PR summary, and can evict an entry that was just used. If LRU is intended, update the entry's insertion order on read (delete+set) or maintain an explicit recency structure.

Copilot uses AI. Check for mistakes.
Divkix added 3 commits March 25, 2026 18:28
…ams eagerly

createFromFetch() returns a Thenable (not instanceof Promise), so the
async branch was never taken. The raw unresolved Thenable was set as
React children, causing suspension without a Suspense boundary and
empty content after cross-route navigations.

- Duck-type the .then() check to handle both Promises and Thenables
- Replace stageClientParams with applyClientParams so useSyncExternalStore
  subscribers see correct params immediately
- Remove dead stageClientParams function and replaceClientParamsWithoutNotify import

Fixes useParams E2E failures in CI (hooks.spec.ts:161, hooks.spec.ts:180).
The Flight Thenable from createFromFetch was passed unresolved to
renderNavigationPayload/updateBrowserTree. The .then() callback fired
unreliably across consecutive navigations, causing empty content.

Await createFromFetch in both navigation paths (cached and fresh) so
renderNavigationPayload always receives a resolved ReactNode. Add stale
navigation checks after the new await points.

The HMR handler and server action handler already awaited correctly.
Preserve the X-Vinext-Params header when snapshotting prefetched and visited RSC responses so cached navigations keep dynamic params intact. Stage client params until the URL commit lands so useParams, history state, and redirect handling stay aligned during App Router transitions.
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.

Cross-route client navigation hangs in Firefox (startTransition never commits)

2 participants