@@ -7,6 +7,11 @@ import {type ReactElement} from 'react';
77import { setNavigationPromise } from './Router' ;
88import { ToastQueue } from '@react-spectrum/s2' ;
99
10+ if ( 'scrollRestoration' in history ) {
11+ // Disable browser's automatic scroll restoration since we handle it manually
12+ history . scrollRestoration = 'manual' ;
13+ }
14+
1015// Hydrate initial RSC payload embedded in the HTML.
1116let updateRoot = hydrate ( {
1217 // Intercept HMR window reloads, and do it with RSC instead.
@@ -19,16 +24,56 @@ let updateRoot = hydrate({
1924let currentNavigationId = 0 ;
2025let currentAbortController : AbortController | null = null ;
2126
27+ interface HistoryState {
28+ scrollTop ?: number ,
29+ windowScrollTop ?: number
30+ }
31+
32+ function getScrollContainer ( ) : HTMLElement | null {
33+ return document . querySelector ( 'main' ) ;
34+ }
35+
36+ function saveScrollPosition ( ) {
37+ let scrollContainer = getScrollContainer ( ) ;
38+ let scrollTop = scrollContainer ?. scrollTop ?? 0 ;
39+ let windowScrollTop = window . scrollY ;
40+ let state : HistoryState = {
41+ ...( history . state as HistoryState | null ) ,
42+ scrollTop,
43+ windowScrollTop
44+ } ;
45+ history . replaceState ( state , '' , location . href ) ;
46+ }
47+
48+ function restoreScrollPosition ( state : HistoryState | null ) {
49+ if ( state ?. scrollTop != null || state ?. windowScrollTop != null ) {
50+ requestAnimationFrame ( ( ) => {
51+ let scrollContainer = getScrollContainer ( ) ;
52+ if ( scrollContainer && state . scrollTop != null ) {
53+ scrollContainer . scrollTop = state . scrollTop ;
54+ }
55+ if ( state . windowScrollTop != null ) {
56+ window . scrollTo ( 0 , state . windowScrollTop ) ;
57+ }
58+ } ) ;
59+ }
60+ }
61+
2262// A very simple router. When we navigate, we'll fetch a new RSC payload from the server,
2363// and in a React transition, stream in the new page. Once complete, we'll pushState to
2464// update the URL in the browser.
25- async function navigate ( pathname : string , push = false ) {
65+ async function navigate ( pathname : string , push = false , popstateState : HistoryState | null = null ) {
2666 let url = new URL ( pathname , location . href ) ;
2767 let basePath = url . pathname ;
2868 let pathAnchor = url . hash . slice ( 1 ) ;
2969 let currentPath = location . pathname ;
3070 let isSamePageAnchor = ( ! basePath || basePath === currentPath ) && pathAnchor ;
3171
72+ // Save scroll position to current history entry before navigating away
73+ if ( push ) {
74+ saveScrollPosition ( ) ;
75+ }
76+
3277 if ( isSamePageAnchor ) {
3378 if ( push ) {
3479 history . pushState ( null , '' , pathname ) ;
@@ -86,10 +131,19 @@ async function navigate(pathname: string, push = false) {
86131 push = false ;
87132 }
88133
89- // Reset scroll if navigating to a different page without an anchor
90- if ( currentPath !== newBasePath && ! newPathAnchor ) {
134+ // Handle scroll position
135+ if ( popstateState ) {
136+ // Restore scroll position from history state (back/forward navigation)
137+ restoreScrollPosition ( popstateState ) ;
138+ } else if ( currentPath !== newBasePath && ! newPathAnchor ) {
139+ // Reset scroll for forward navigation to a different page without an anchor
140+ let scrollContainer = getScrollContainer ( ) ;
141+ if ( scrollContainer ) {
142+ scrollContainer . scrollTop = 0 ;
143+ }
91144 window . scrollTo ( 0 , 0 ) ;
92145 } else if ( newPathAnchor ) {
146+ // Scroll to anchor
93147 let element = document . getElementById ( newPathAnchor ) ;
94148 if ( element ) {
95149 element . scrollIntoView ( ) ;
@@ -244,11 +298,22 @@ document.addEventListener('click', e => {
244298 }
245299} ) ;
246300
247- // When the user clicks the back button, navigate with RSC.
248- window . addEventListener ( 'popstate' , ( ) => {
249- navigate ( location . pathname + location . search + location . hash ) ;
301+ // When the user clicks the back/forward button, navigate with RSC.
302+ window . addEventListener ( 'popstate' , ( e ) => {
303+ navigate ( location . pathname + location . search + location . hash , false , e . state as HistoryState | null ) ;
250304} ) ;
251305
306+ // Save scroll position to history state when scrolling stops.
307+ let scrollSaveTimeout : ReturnType < typeof setTimeout > | null = null ;
308+ function onScroll ( ) {
309+ if ( scrollSaveTimeout ) {
310+ clearTimeout ( scrollSaveTimeout ) ;
311+ }
312+ scrollSaveTimeout = setTimeout ( saveScrollPosition , 150 ) ;
313+ }
314+
315+ window . addEventListener ( 'scroll' , onScroll , { passive : true , capture : true } ) ;
316+
252317function scrollToCurrentHash ( ) {
253318 if ( ! location . hash || location . hash === '#' ) {
254319 return ;
@@ -276,7 +341,5 @@ function scrollToCurrentHash() {
276341if ( document . readyState === 'complete' || document . readyState === 'interactive' ) {
277342 scrollToCurrentHash ( ) ;
278343} else {
279- window . addEventListener ( 'DOMContentLoaded' , ( ) => {
280- scrollToCurrentHash ( ) ;
281- } , { once : true } ) ;
344+ window . addEventListener ( 'DOMContentLoaded' , scrollToCurrentHash , { once : true } ) ;
282345}
0 commit comments