cloudflare / cloudflare/vinext

App Router client navigation: replay Back/Forward traversal missed before hydration (Navigation API detection)

Open
#2,822 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
Dominant language
TypeScript
Stars
8.8k
Forks
406
Avg merge
2d 6h
Merged PRs (30d)
120

Description

## Next.js Change

**Commit:** [`0fd2e51`](https://github.com/vercel/next.js/commit/0fd2e51ed65726c5b2e2e2650ce9f8ca4e007b3c)
**PR:** [#96252](https://github.com/vercel/next.js/pull/96252) (relands #95682)

## What changed

Fixes a race when the user presses **Back/Forward before the App Router's `popstate` listener is installed** (i.e. before/during hydration). The browser moves to a different history entry than the one the document was activated on, and the resulting `popstate` fires with nobody listening — so the traversal is silently lost and the UI stays on the wrong entry.

The fix uses the **Navigation API** to detect during hydration that the current history entry differs from the activation entry, and if so replays the missed traversal with logic mirroring `onPopState`.

### Mechanism (from the diff, `client/components/app-router.tsx`)

- New `hasMissedTraversal()`: returns true when `window.navigation` exists and `navigation.activation.entry.key !== navigation.currentEntry.key`, **and** `window.history.state?.__NA === true` (only app-router-written entries can be restored; others are left unhandled as before). The activation entry is fixed for the document's lifetime and entry keys are stable across `replaceState`, so a key mismatch before the listener is installed means a traversal went unobserved.
- `onPopState` logic is extracted into a standalone `handlePopState(state)` so it can be invoked both from the real event and from the missed-traversal replay. It still reloads when `!state.__NA` (entry pushed by the Pages Router) and dispatches the traverse action (via `startTransition` + `dispatchTraverseAction`) otherwise.
- Two one-shot guards, `checkedMissedTraversalBeforeHistoryWrite` and `checkedMissedTraversalBeforeReplay`:
- In `HistoryUpdater`, before the first history write, if `hasMissedTraversal()` it **skips the write** (which would overwrite the traversed-to entry's state) and instead just records `setLastCommittedTree(tree)`.
- In `Router`'s effect that installs the `popstate` listener, after adding the listener, if `hasMissedTraversal()` it calls `handlePopState(window.history.state)` to replay the missed traversal.

The original reland was reverted (#95853) due to a React hang under Activity applying the state update; that was fixed upstream (react/react#37135) and synced, so the fix is safe again.

## Impact on vinext

vinext reimplements App Router client-side navigation and history handling (its own `next/navigation` + router shims / browser entry). If vinext only handles `popstate` via an event listener installed after hydration, it has the same race: a Back/Forward pressed before hydration completes will be dropped, leaving the app on the wrong route/history entry with no visible navigation.

What to check/do:

1. **Detect missed traversals during hydration** using the Navigation API: compare `navigation.activation.entry.key` to `navigation.currentEntry.key`, gated on the current `history.state` being an app-router-written entry.
2. **Skip the first history write when a traversal was missed**, so the router doesn't clobber the traversed-to entry's state; still commit the rendered tree.
3. **Replay the missed traversal** right after installing the `popstate` listener, using the same handler path as a normal `popstate` (reload on non-app-router entries, otherwise dispatch the traverse/restore action).
4. **Use one-shot guards** so the missed-traversal check runs at most once for the history-write path and once for the replay path.
5. **Port the tests.** Next.js added `test/e2e/app-dir/back-before-hydration/` covering Back before hydration across plain pages, search-param routes, Suspense boundaries, and third-party `pushState`.

### Notes

- The Navigation API is available in Chromium-based browsers; the guard no-ops where `window.navigation` is undefined (Firefox/Safari), matching Next.js's behavior of leaving the traversal unhandled there.

## Related

- #2810 — App Router navigation scroll: respect root `scroll-padding-top`
- #2809 — App Router client navigation: strip hash from stored canonical URL

Contributor guide

Open the contributing guide

Research direction

Start by tracing vinext's next/navigation router shims and browser entry to find the popstate listener and first history write. Compare that flow with client/components/app-router.tsx and the test/e2e/app-dir/back-before-hydration/ cases. Done means Back/Forward before hydration is detected and replayed without clobbering history, while non-app-router entries retain reload behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, typescript
Domain
frontend, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.