[PR #690] [MERGED] fix: cross-route client navigation hangs in Firefox (#652) #773

Closed
opened 2026-05-06 13:10:02 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/690
Author: @Divkix
Created: 3/26/2026
Status: Merged
Merged: 4/1/2026
Merged by: @james-elicx

Base: mainHead: fix/firefox-navigation-hang


📝 Commits (10+)

  • 030d111 fix: cross-route client navigation hangs in Firefox (#652)
  • bdb689d fix: detect React Flight Thenables in updateBrowserTree and apply params eagerly
  • c19abc7 fix: await createFromFetch before passing RSC payload to tree
  • e6978c0 fix: preserve app router params across cached navigation
  • f062859 fix: address review feedback on navigation caching and history management
  • 0d5b843 merge: resolve conflict for PR 690 Firefox navigation fix
  • 0f4f72b fix(navigation): address PR review feedback for Firefox navigation fix
  • ffbb964 fix: prevent stale navigation bailout from leaking staged params
  • ab477b7 merge: resolve conflicts with origin/main for instrumentation-client support
  • 939f0db fix: remove duplicate onRouterTransitionStart call and fix formatting

📊 Changes

18 files changed (+1733 additions, -333 deletions)

View changed files

📝 packages/vinext/src/global.d.ts (+12 -4)
📝 packages/vinext/src/server/app-browser-entry.ts (+578 -50)
📝 packages/vinext/src/shims/form.tsx (+7 -8)
📝 packages/vinext/src/shims/link.tsx (+19 -100)
📝 packages/vinext/src/shims/navigation.ts (+580 -153)
tests/e2e/app-router/navigation-regressions.spec.ts (+239 -0)
tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx (+29 -0)
tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx (+37 -0)
tests/fixtures/app-basic/app/nav-flash/list/page.tsx (+26 -0)
tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx (+22 -0)
tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx (+20 -0)
tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx (+19 -0)
tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx (+26 -0)
tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx (+27 -0)
tests/fixtures/app-basic/app/nav-flash/slow-route/[value]/page.tsx (+28 -0)
📝 tests/fixtures/app-basic/app/page.tsx (+6 -0)
📝 tests/form.test.ts (+27 -17)
📝 tests/prefetch-cache.test.ts (+31 -1)

📄 Description

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 — LRU via delete+re-insert promotion on hit, evict oldest-inserted when over capacity), 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)

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/690 **Author:** [@Divkix](https://github.com/Divkix) **Created:** 3/26/2026 **Status:** ✅ Merged **Merged:** 4/1/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/firefox-navigation-hang` --- ### 📝 Commits (10+) - [`030d111`](https://github.com/cloudflare/vinext/commit/030d111e7cf3bc474487902794ed15987425c525) fix: cross-route client navigation hangs in Firefox (#652) - [`bdb689d`](https://github.com/cloudflare/vinext/commit/bdb689d307274a6a8dcb38ec57c2fbfe9070af48) fix: detect React Flight Thenables in updateBrowserTree and apply params eagerly - [`c19abc7`](https://github.com/cloudflare/vinext/commit/c19abc70c2aad251c3475b003be6da80a427e6d8) fix: await createFromFetch before passing RSC payload to tree - [`e6978c0`](https://github.com/cloudflare/vinext/commit/e6978c030336a6b79c23c0c0e346f3c6a5cd1270) fix: preserve app router params across cached navigation - [`f062859`](https://github.com/cloudflare/vinext/commit/f062859b715dbd54bf28ea6f7cd20851bb54e924) fix: address review feedback on navigation caching and history management - [`0d5b843`](https://github.com/cloudflare/vinext/commit/0d5b84353896b88bbb389063e7608fc0eb544a2c) merge: resolve conflict for PR 690 Firefox navigation fix - [`0f4f72b`](https://github.com/cloudflare/vinext/commit/0f4f72b6eb03b5015bf5416493871a603e4a4b37) fix(navigation): address PR review feedback for Firefox navigation fix - [`ffbb964`](https://github.com/cloudflare/vinext/commit/ffbb9640e52f9980050fb3a7c9887b2d7d10ada3) fix: prevent stale navigation bailout from leaking staged params - [`ab477b7`](https://github.com/cloudflare/vinext/commit/ab477b73ca06d0b20c7323484ba48b935c11e6d0) merge: resolve conflicts with origin/main for instrumentation-client support - [`939f0db`](https://github.com/cloudflare/vinext/commit/939f0dbcc1f563ce6896fef9b3318365fe011e56) fix: remove duplicate onRouterTransitionStart call and fix formatting ### 📊 Changes **18 files changed** (+1733 additions, -333 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/global.d.ts` (+12 -4) 📝 `packages/vinext/src/server/app-browser-entry.ts` (+578 -50) 📝 `packages/vinext/src/shims/form.tsx` (+7 -8) 📝 `packages/vinext/src/shims/link.tsx` (+19 -100) 📝 `packages/vinext/src/shims/navigation.ts` (+580 -153) ➕ `tests/e2e/app-router/navigation-regressions.spec.ts` (+239 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx` (+29 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx` (+37 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/list/page.tsx` (+26 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx` (+22 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx` (+20 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx` (+19 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx` (+26 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx` (+27 -0) ➕ `tests/fixtures/app-basic/app/nav-flash/slow-route/[value]/page.tsx` (+28 -0) 📝 `tests/fixtures/app-basic/app/page.tsx` (+6 -0) 📝 `tests/form.test.ts` (+27 -17) 📝 `tests/prefetch-cache.test.ts` (+31 -1) </details> ### 📄 Description ## Summary Fixes #652 — `startTransition` 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 — LRU via delete+re-insert promotion on hit, evict oldest-inserted when over capacity), 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 - [x] `vp check` passes (only pre-existing benchmark errors remain) - [x] `tests/shims.test.ts` — 742 tests pass - [x] `tests/form.test.ts` — updated assertions for new navigate signature, all pass - [x] `tests/prefetch-cache.test.ts` — compatible with new `PrefetchCacheEntry` type, all pass - [x] `tests/link.test.ts` — all pass - [x] `tests/routing.test.ts` — all pass - [x] `tests/app-router.test.ts` — all pass - [x] `tests/entry-templates.test.ts` — all pass - [x] 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 - [x] CI: full Vitest suite - [x] CI: Playwright E2E (chromium, firefox, webkit) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:10:02 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#773
No description provided.