From f9ab231a1a0305dc72410d48c3e4bc8969e156e4 Mon Sep 17 00:00:00 2001 From: Kishor <124120029+moltresIn@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:38:24 +0530 Subject: [PATCH] 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 --- src/pages/_app.tsx | 70 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 80a0a0f8641..c1adc87265c 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -41,10 +41,74 @@ export default function MyApp({Component, pageProps}: AppProps) { // However, we *also* don't want Safari grey screen during the back swipe gesture. // Seems like it doesn't hurt to enable auto restore *and* Next.js logic at the same time. history.scrollRestoration = 'auto'; - } else { - // For other browsers, let Next.js set scrollRestoration to 'manual'. - // It seems to work better for Chrome and Firefox which don't animate the back swipe. + return; } + + // For other browsers, Next.js keeps scrollRestoration as 'manual'. + // That breaks the browser back button after in-page hash navigations + // (/page -> /page#section -> back): the URL updates but scroll stays put. + // Save the pre-hash scroll on the current history entry, then restore it + // when the user navigates back to a hash-less URL on the same page. + // See https://github.com/reactjs/react.dev/issues/787 + const SCROLL_KEY = '__reactDevHashScrollY'; + + const saveScrollBeforeHashNavigation = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const anchor = target.closest('a'); + if (!anchor) { + return; + } + const href = anchor.getAttribute('href'); + if (!href || !href.startsWith('#') || href === '#') { + return; + } + // Ignore modified clicks / new tabs — those don't use history the same way. + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + history.replaceState( + Object.assign({}, history.state, {[SCROLL_KEY]: window.scrollY}), + '' + ); + }; + + const restoreScrollAfterHashBack = (event: PopStateEvent) => { + if (window.location.hash) { + return; + } + const state = event.state as {[SCROLL_KEY]?: number} | null; + const scrollY = state?.[SCROLL_KEY]; + if (typeof scrollY !== 'number') { + return; + } + // Defer so we run after any competing scroll resets from the router. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + }); + }; + + document.addEventListener('click', saveScrollBeforeHashNavigation, true); + window.addEventListener('popstate', restoreScrollAfterHashBack); + return () => { + document.removeEventListener( + 'click', + saveScrollBeforeHashNavigation, + true + ); + window.removeEventListener('popstate', restoreScrollAfterHashBack); + }; }, []); useEffect(() => {