Skip to content

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

Description

@github-actions

Next.js Change

Commit: 0fd2e51
PR: #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

Metadata

Metadata

Assignees

No one assigned

    Labels

    nextjs-trackingTracking issue for a Next.js canary change relevant to vinext

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions