@@ -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