cloudflare / cloudflare/vinext
App Router client navigation: replay Back/Forward traversal missed before hydration (Navigation API detection)
- 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
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