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.ts — restorePopstateScrollPosition()
packages/vinext/src/server/app-browser-popstate.ts
packages/vinext/src/shims/navigation.ts — history state scroll save before push
Recommendation
- Reproduce with a focused fixture: long App Router page -> scroll deep -> navigate away or open intercepted detail -> browser back -> assert original
scrollY is restored.
- Store scroll position as metadata for the history entry being left, not as global router state.
- Trigger restoration from the traversal lifecycle after the target entry restore/commit path has completed.
- 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.
- Keep hash restoration and explicit
scroll={false} behavior separate from popstate saved-scroll restoration.
- 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.
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.tsapp dir - navigation > scroll restoration > should restore original scroll position when navigating backtest/e2e/reload-scroll-backforward-restoration/index.test.tsreload-scroll-back-restoration > should restore the scroll position on navigating backreload-scroll-back-restoration > should restore the scroll position on navigating forwardArchitectural 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.
scrollY, the document must be able to represent it; otherwise the browser will clamp and silently lose the intended position.Relevant Vinext source
packages/vinext/src/server/app-browser-entry.ts—restorePopstateScrollPosition()packages/vinext/src/server/app-browser-popstate.tspackages/vinext/src/shims/navigation.ts— history state scroll save before pushRecommendation
scrollYis restored.document.scrollingElement.scrollHeightcan represent the requested position, with a small bounded frame budget and a final best-effort attempt.scroll={false}behavior separate from popstate saved-scroll restoration.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.