Files
Nathan Nguyen 28980b01f1 fix: prevent URL/content mismatch on rapid Pages Router navigation (#782)
* fix: prevent URL/content mismatch on rapid Pages Router navigation

Replace the boolean `_navInProgress` guard in navigateClient() with a
monotonically increasing navigation ID + AbortController. The old guard
silently dropped the second navigation when two push() calls overlapped,
leaving the URL bar showing B while page content showed A.

Now each navigateClient() call:
- Aborts the previous in-flight fetch via AbortController
- Checks after every await whether a newer navigation has superseded it
- Throws NavigationCancelledError (with .cancelled = true) when stale

Callers (push/replace in both hook and singleton, plus popstate) catch
the error and emit routeChangeError for cancelled navigations, matching
Next.js behavior. routeChangeComplete only fires for navigations that
actually render.

Also fixes: failed navigations (non-OK response, missing data) now emit
routeChangeError instead of silently emitting routeChangeComplete before
the hard redirect.

* chore: trigger CI

* refactor: clarify routeChangeError/hard-nav contract and simplify catch blocks

- Add comment to !res.ok path explaining the window.location.href + throw
  ordering contract: the assignment schedules navigation as a task, so
  synchronous routeChangeError listeners observe the error before unload.
  Document the contract requirement: listeners MUST be synchronous.

- Simplify all 4 push/replace catch blocks: extract routerEvents.emit()
  before the instanceof check so the emit is unconditional and the if-branch
  only determines the return value. Add clarifying comment explaining why
  cancelled navigations return true (superseded, not failed — matches
  Next.js behaviour).

- Simplify popstate .then() rejection handler: remove redundant
  if/instanceof guard since both paths emitted identical arguments; add
  comment noting there is no return-value distinction in a void handler.

* test: add signal-aware and stale-first navigation cancellation tests

Two new tests for the Pages Router concurrent navigation suite:

1. "abort signal fires when navigation is superseded" — uses a
   signal-aware fetch mock where the first fetch rejects with AbortError
   when its signal is aborted. Verifies the AbortError → NavigationCancelledError
   conversion path end-to-end (i.e. the abort signal integration actually works,
   not just the assertStillCurrent checkpoint).

2. "stale response arriving first" variant — fetchA (stale) resolves before
   fetchB (winner). Exercises assertStillCurrent() checkpoints when the stale
   navigation gets further through the pipeline (past the fetch, into HTML
   parsing) before being cancelled. Verifies stale data never reaches the DOM.

* test: remove Router.events listeners in finally blocks

Store each Router.events handler in a named variable before the try
block and call Router.events.off() in the finally block. Prevents
handlers from accumulating on the module-level routerEvents singleton
across the 6 concurrent navigation tests.

* fix: address review feedback — defer __NEXT_DATA__ write, extract helper, add hard-nav fallback

- Defer window.__NEXT_DATA__ assignment until just before root.render() so
  a stale navigation can never briefly pollute the global between
  assertStillCurrent() checkpoints.
- Extract runNavigateClient() helper to deduplicate the identical 12-line
  try/catch block across useRouter().push/replace and Router.push/replace.
- Add hard-navigation fallback (window.location.href) for non-cancel errors
  in both the helper and the popstate handler, fixing a regression where
  unexpected errors (JSON parse, import failure) left URL and content out
  of sync.
- Add test asserting __NEXT_DATA__ is not stale at the moment
  routeChangeError fires for a cancelled navigation.

* fix: avoid duplicate hard navigation fallback

* fix: align popstate hard-nav handling

* chore: clarify navigation fallback invariants

* chore: tighten navigation regression proof
2026-04-07 12:51:05 +01:00
..
2026-02-24 09:29:39 -06:00

vinext

This file is overwritten from the repo root README.md during prepack. Edit the root README.md instead.