Skip to content

Commit 299e241

Browse files
authored
docs: fix scroll position restoration when navigating back/forward (#9242)
* fix scroll position restoration when navigating back/forward * save scroll position to history * set history.scrollRestoration = 'manual'
1 parent 950141c commit 299e241

File tree

1 file changed

+72
-9
lines changed

1 file changed

+72
-9
lines changed

packages/dev/s2-docs/src/client.tsx

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {type ReactElement} from 'react';
77
import {setNavigationPromise} from './Router';
88
import {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.
1116
let updateRoot = hydrate({
1217
// Intercept HMR window reloads, and do it with RSC instead.
@@ -19,16 +24,56 @@ let updateRoot = hydrate({
1924
let currentNavigationId = 0;
2025
let 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+
252317
function scrollToCurrentHash() {
253318
if (!location.hash || location.hash === '#') {
254319
return;
@@ -276,7 +341,5 @@ function scrollToCurrentHash() {
276341
if (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

Comments
 (0)