Skip to content

App Router: restore popstate scroll after traversal content is restorable #1367

@NathanDrake2406

Description

@NathanDrake2406

Created from the Next.js Deploy Suite failure inventory: https://github.com/cloudflare/vinext/actions/runs/26163751905
Related foundation: #1366

Problem

Vinext currently restores popstate scroll with a single requestAnimationFrame(() => window.scrollTo(x, y)) after traversal navigation settles. If the previous route's document height or client-expanded UI has not been restored yet, the browser clamps the requested scroll position. This causes deep-list back navigation to land near the top or near a load-more boundary instead of the original row.

This is visible in real apps with long lists, intercepted detail routes, and client-side "show more" state.

Failing Next.js tests

  • test/e2e/app-dir/navigation/navigation.test.ts
    • app dir - navigation > scroll restoration > should restore original scroll position when navigating back
  • test/e2e/reload-scroll-backforward-restoration/index.test.ts
    • reload-scroll-back-restoration > should restore the scroll position on navigating back
    • reload-scroll-back-restoration > should restore the scroll position on navigating forward

Architectural contract

Popstate scroll restoration is a lifecycle effect that consumes a restored history entry. It must not be used as a substitute for restoring the old route/UI/cache state.

  • Save scroll per browser history entry before leaving that entry.
  • Restore scroll only after the traversal commit has made the destination world visible or has deliberately fallen back.
  • Before applying a deep saved scrollY, the document must be able to represent it; otherwise the browser will clamp and silently lose the intended position.
  • Any retry/wait mechanism must be bounded and tied to traversal lifecycle, not an unstructured timing loop.
  • Missing or malformed scroll metadata must degrade to normal hash/default behavior.

Relevant Vinext source

  • packages/vinext/src/server/app-browser-entry.tsrestorePopstateScrollPosition()
  • packages/vinext/src/server/app-browser-popstate.ts
  • packages/vinext/src/shims/navigation.ts — history state scroll save before push

Recommendation

  1. Reproduce with a focused fixture: long App Router page -> scroll deep -> navigate away or open intercepted detail -> browser back -> assert original scrollY is restored.
  2. Store scroll position as metadata for the history entry being left, not as global router state.
  3. Trigger restoration from the traversal lifecycle after the target entry restore/commit path has completed.
  4. Apply the saved scroll when document.scrollingElement.scrollHeight can represent the requested position, with a small bounded frame budget and a final best-effort attempt.
  5. Keep hash restoration and explicit scroll={false} behavior separate from popstate saved-scroll restoration.
  6. Add coverage for both success and degraded fallback: restorable long page, missing scroll state, and impossible/evicted target height.

Dependency

This should be layered after, or at least designed with, history-entry restoration from #1366. A scroll retry without restored route/UI state only masks the race and does not preserve client-expanded list state.

Metadata

Metadata

Assignees

No one assigned

    Type

    No fields configured for Bug.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions