Skip to content

fix(app-router): guard popstate scroll restore on stale navigation#7

Closed
NathanDrake2406 wants to merge 4 commits into
mainfrom
nathan/fix-popstate-scroll-race
Closed

fix(app-router): guard popstate scroll restore on stale navigation#7
NathanDrake2406 wants to merge 4 commits into
mainfrom
nathan/fix-popstate-scroll-race

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Owner

Summary

Prevents stale App Router popstate completions from restoring an old history scroll position after rapid back/forward races.

Problem

window.__VINEXT_RSC_NAVIGATE__ is invoked for every popstate, and we currently run restorePopstateScrollPosition(event.state) in .finally unconditionally. If multiple back/forward traversals run concurrently, an older navigation can resolve after a newer one and incorrectly restore scroll for the stale history entry.

This manifests as jumpy/incorrect scroll when users navigate quickly or under network jitter.

Why this change is meaningful

This is not speculative UI behavior tuning; it is a concrete race-condition fix affecting user-visible navigation state:

  • it changes externally observable behavior (scroll restoration correctness)
  • it closes a stale async completion window
  • it aligns with existing vinext nav lifecycle semantics (activeNavigationId / isCurrentNavigation) and with Next.js intent to drop superseded traversals

Implementation

In packages/vinext/src/server/app-browser-entry.ts:

  • capture the navigate session id (navId) for each popstate-triggered RSC navigation via browserNavigationController.getActiveNavigationId()
  • skip scroll restoration in the .finally handler unless that navId is still current
  • keep the existing __VINEXT_RSC_PENDING__ identity guard for clearing only the current pending marker

Net effect: only the winner traversal may restore scroll, and superseded traversals settle quietly.

Next.js parity / reference

Validation

  • Implemented as a focused diff in the App Router popstate finalizer in the browser entry.
  • Commit hash: 0abbf083

The popstate scroll race tests imported app-browser-entry directly, which pulls virtual modules that the unit runner cannot load outside the browser entry build graph. That made the PR fail before the new regression tests could run.

Extract the popstate restoration coordinator into a normal helper module, wire the browser entry through it, and keep the tests' fake RSC navigation promises identical to the pending marker they assert against.
@NathanDrake2406
NathanDrake2406 deleted the nathan/fix-popstate-scroll-race branch May 21, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant