[PR #782] [MERGED] fix: prevent URL/content mismatch on rapid Pages Router navigation #842

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

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/782
Author: @NathanDrake2406
Created: 4/4/2026
Status: Merged
Merged: 4/7/2026
Merged by: @james-elicx

Base: mainHead: fix/pages-router-nav-race


📝 Commits (10+)

  • dd8c624 fix: prevent URL/content mismatch on rapid Pages Router navigation
  • f778364 chore: trigger CI
  • 832d6c7 refactor: clarify routeChangeError/hard-nav contract and simplify catch blocks
  • 8a07411 test: add signal-aware and stale-first navigation cancellation tests
  • 4aef8f2 test: remove Router.events listeners in finally blocks
  • 1539d8e fix: address review feedback — defer NEXT_DATA write, extract helper, add hard-nav fallback
  • 0ccdca5 fix: avoid duplicate hard navigation fallback
  • d99c9fb fix: align popstate hard-nav handling
  • 83c3877 chore: clarify navigation fallback invariants
  • 2b6b409 chore: tighten navigation regression proof

📊 Changes

2 files changed (+840 additions, -30 deletions)

View changed files

📝 packages/vinext/src/shims/router.ts (+170 -30)
📝 tests/shims.test.ts (+670 -0)

📄 Description

Summary

  • Replace the boolean _navInProgress guard in navigateClient() with a navigation ID counter + AbortController, fixing a race condition where rapid push() calls left the URL bar showing one page while the content showed another
  • Cancelled (superseded) navigations now emit routeChangeError with .cancelled = true instead of silently completing, matching Next.js behavior
  • Failed navigations (non-OK response, missing __NEXT_DATA__) now emit routeChangeError instead of incorrectly emitting routeChangeComplete before the hard redirect

How it works

Each navigateClient() call increments a monotonic _navigationId and aborts the previous fetch via AbortController. After every await boundary (fetch, res.text, dynamic import, React import, app import), the function checks whether it is still the active navigation. If a newer one has started, it throws NavigationCancelledError. Callers catch this and emit routeChangeError instead of routeChangeComplete.

This mirrors the clc (component load cancel) pattern in Next.js's Pages Router (getCancelledHandler in packages/next/src/shared/lib/router/router.ts).

Test plan

  • Rapid sequential push() calls: last navigation wins, __NEXT_DATA__ reflects the winning page
  • routeChangeComplete does not fire for the superseded navigation
  • routeChangeError fires with .cancelled = true for the superseded navigation
  • Failed navigation (500 response) emits routeChangeError, not routeChangeComplete
  • replace() also cancels a superseded push()
  • All 788 existing shims tests pass (no regressions)

🔄 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/782 **Author:** [@NathanDrake2406](https://github.com/NathanDrake2406) **Created:** 4/4/2026 **Status:** ✅ Merged **Merged:** 4/7/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/pages-router-nav-race` --- ### 📝 Commits (10+) - [`dd8c624`](https://github.com/cloudflare/vinext/commit/dd8c6246dc1853a5b53fb1085405236b1554065e) fix: prevent URL/content mismatch on rapid Pages Router navigation - [`f778364`](https://github.com/cloudflare/vinext/commit/f7783645c19e6e35c12f5e4f745b5776ef4e8d09) chore: trigger CI - [`832d6c7`](https://github.com/cloudflare/vinext/commit/832d6c70d6932600a1806375e82de248e7d9ebcd) refactor: clarify routeChangeError/hard-nav contract and simplify catch blocks - [`8a07411`](https://github.com/cloudflare/vinext/commit/8a07411112a078445d26a626f9ad73414f006c78) test: add signal-aware and stale-first navigation cancellation tests - [`4aef8f2`](https://github.com/cloudflare/vinext/commit/4aef8f2ac51a543a401f2cbc30545440709d576b) test: remove Router.events listeners in finally blocks - [`1539d8e`](https://github.com/cloudflare/vinext/commit/1539d8e1862b462d48ed84d02c3e9a8a182d9e12) fix: address review feedback — defer __NEXT_DATA__ write, extract helper, add hard-nav fallback - [`0ccdca5`](https://github.com/cloudflare/vinext/commit/0ccdca5960388163bf27652b612659c3887e22a1) fix: avoid duplicate hard navigation fallback - [`d99c9fb`](https://github.com/cloudflare/vinext/commit/d99c9fbfb7b208236ed76f0a61e846e7fc724f58) fix: align popstate hard-nav handling - [`83c3877`](https://github.com/cloudflare/vinext/commit/83c387724d5dbf70eda93bccaf2cfda75856bf86) chore: clarify navigation fallback invariants - [`2b6b409`](https://github.com/cloudflare/vinext/commit/2b6b409254dcda1b7e49ce12bac4e71d724cd856) chore: tighten navigation regression proof ### 📊 Changes **2 files changed** (+840 additions, -30 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/shims/router.ts` (+170 -30) 📝 `tests/shims.test.ts` (+670 -0) </details> ### 📄 Description ## Summary - Replace the boolean `_navInProgress` guard in `navigateClient()` with a navigation ID counter + AbortController, fixing a race condition where rapid `push()` calls left the URL bar showing one page while the content showed another - Cancelled (superseded) navigations now emit `routeChangeError` with `.cancelled = true` instead of silently completing, matching Next.js behavior - Failed navigations (non-OK response, missing `__NEXT_DATA__`) now emit `routeChangeError` instead of incorrectly emitting `routeChangeComplete` before the hard redirect ## How it works Each `navigateClient()` call increments a monotonic `_navigationId` and aborts the previous fetch via `AbortController`. After every `await` boundary (fetch, res.text, dynamic import, React import, app import), the function checks whether it is still the active navigation. If a newer one has started, it throws `NavigationCancelledError`. Callers catch this and emit `routeChangeError` instead of `routeChangeComplete`. This mirrors the `clc` (component load cancel) pattern in Next.js's Pages Router (`getCancelledHandler` in `packages/next/src/shared/lib/router/router.ts`). ## Test plan - [x] Rapid sequential `push()` calls: last navigation wins, `__NEXT_DATA__` reflects the winning page - [x] `routeChangeComplete` does not fire for the superseded navigation - [x] `routeChangeError` fires with `.cancelled = true` for the superseded navigation - [x] Failed navigation (500 response) emits `routeChangeError`, not `routeChangeComplete` - [x] `replace()` also cancels a superseded `push()` - [x] All 788 existing shims tests pass (no regressions) --- <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:25 +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#842
No description provided.