Skip to content

Commit f9ab231

Browse files
Fix scroll restore when using browser back after hash links
With Next.js scrollRestoration set to manual, going back from an in-page fragment left the URL correct but kept the hash scroll position. Save scrollY on the history entry before hash navigation and restore it on popstate. Fixes #787. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c7d6b70 commit f9ab231

1 file changed

Lines changed: 67 additions & 3 deletions

File tree

src/pages/_app.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,74 @@ export default function MyApp({Component, pageProps}: AppProps) {
4141
// However, we *also* don't want Safari grey screen during the back swipe gesture.
4242
// Seems like it doesn't hurt to enable auto restore *and* Next.js logic at the same time.
4343
history.scrollRestoration = 'auto';
44-
} else {
45-
// For other browsers, let Next.js set scrollRestoration to 'manual'.
46-
// It seems to work better for Chrome and Firefox which don't animate the back swipe.
44+
return;
4745
}
46+
47+
// For other browsers, Next.js keeps scrollRestoration as 'manual'.
48+
// That breaks the browser back button after in-page hash navigations
49+
// (/page -> /page#section -> back): the URL updates but scroll stays put.
50+
// Save the pre-hash scroll on the current history entry, then restore it
51+
// when the user navigates back to a hash-less URL on the same page.
52+
// See https://github.com/reactjs/react.dev/issues/787
53+
const SCROLL_KEY = '__reactDevHashScrollY';
54+
55+
const saveScrollBeforeHashNavigation = (event: MouseEvent) => {
56+
const target = event.target;
57+
if (!(target instanceof Element)) {
58+
return;
59+
}
60+
const anchor = target.closest('a');
61+
if (!anchor) {
62+
return;
63+
}
64+
const href = anchor.getAttribute('href');
65+
if (!href || !href.startsWith('#') || href === '#') {
66+
return;
67+
}
68+
// Ignore modified clicks / new tabs — those don't use history the same way.
69+
if (
70+
event.defaultPrevented ||
71+
event.button !== 0 ||
72+
event.metaKey ||
73+
event.ctrlKey ||
74+
event.shiftKey ||
75+
event.altKey
76+
) {
77+
return;
78+
}
79+
history.replaceState(
80+
Object.assign({}, history.state, {[SCROLL_KEY]: window.scrollY}),
81+
''
82+
);
83+
};
84+
85+
const restoreScrollAfterHashBack = (event: PopStateEvent) => {
86+
if (window.location.hash) {
87+
return;
88+
}
89+
const state = event.state as {[SCROLL_KEY]?: number} | null;
90+
const scrollY = state?.[SCROLL_KEY];
91+
if (typeof scrollY !== 'number') {
92+
return;
93+
}
94+
// Defer so we run after any competing scroll resets from the router.
95+
requestAnimationFrame(() => {
96+
requestAnimationFrame(() => {
97+
window.scrollTo(0, scrollY);
98+
});
99+
});
100+
};
101+
102+
document.addEventListener('click', saveScrollBeforeHashNavigation, true);
103+
window.addEventListener('popstate', restoreScrollAfterHashBack);
104+
return () => {
105+
document.removeEventListener(
106+
'click',
107+
saveScrollBeforeHashNavigation,
108+
true
109+
);
110+
window.removeEventListener('popstate', restoreScrollAfterHashBack);
111+
};
48112
}, []);
49113

50114
useEffect(() => {

0 commit comments

Comments
 (0)