@@ -131,45 +131,21 @@ const _scrollPositions: Record<string, { x: number; y: number }> = {};
131131
132132/**
133133 * ScrollRestoration component that saves and restores scroll positions across
134- * client-side navigation. Uses the browser's History API to track scroll
135- * positions keyed by URL pathname.
134+ * client-side navigation.
136135 *
137- * On mount, it:
138- * - Disables the browser's native scroll restoration
139- * - Patches pushState/replaceState to save scroll positions before navigation
140- * - Listens for popstate to save the leaving page's scroll
141- * - Restores any previously saved scroll position for the current pathname
142- *
143- * Scroll positions are consumed on restore so subsequent re-renders on the same
144- * page do not snap the user back. The module-level store survives the
145- * component's unmount/remount cycle across route transitions.
136+ * The one-time mount effect patches pushState/replaceState to save scroll
137+ * before navigation and registers a popstate listener. The post-render effect
138+ * (no deps) restores scroll for the current pathname whenever a saved position
139+ * exists — the position is kept alive in the module store so it remains
140+ * available across Preact's render commit cycle.
146141 */
147142export function ScrollRestoration ( { } : ScrollRestorationProps ) : null {
148143 const lastPathRef = React . useRef ( window . location . pathname ) ;
149144
145+ // One-time setup: patch history methods and register popstate listener.
150146 React . useEffect ( ( ) => {
151- const currentKey = window . location . pathname ;
152- if ( currentKey in _scrollPositions ) {
153- const savedPos = _scrollPositions [ currentKey ] ;
154- // Retry scroll restoration each animation frame until success.
155- // Preact may perform multiple render commits during a single
156- // navigation, and each commit can reset the scroll position.
157- // We keep retrying until the position actually sticks.
158- let remaining = 5 ; // max 5 retries (~80ms max)
159- const tryRestore = ( ) => {
160- if ( window . scrollY === savedPos . y && window . scrollX === savedPos . x ) {
161- delete _scrollPositions [ currentKey ] ;
162- return ;
163- }
164- window . scrollTo ( savedPos . x , savedPos . y ) ;
165- if ( -- remaining > 0 ) requestAnimationFrame ( tryRestore ) ;
166- } ;
167- tryRestore ( ) ;
168- }
169-
170147 window . history . scrollRestoration = "manual" ;
171148
172- // Patch pushState to save scroll before URL changes
173149 const originalPushState = window . history . pushState . bind ( window . history ) ;
174150 window . history . pushState = ( data , unused , url ) => {
175151 const key = window . location . pathname ;
@@ -178,7 +154,6 @@ export function ScrollRestoration({}: ScrollRestorationProps): null {
178154 lastPathRef . current = window . location . pathname ;
179155 } ;
180156
181- // Patch replaceState to save scroll before URL changes
182157 const originalReplaceState = window . history . replaceState . bind (
183158 window . history ,
184159 ) ;
@@ -189,7 +164,6 @@ export function ScrollRestoration({}: ScrollRestorationProps): null {
189164 lastPathRef . current = window . location . pathname ;
190165 } ;
191166
192- // On popstate, save the scroll of the page we're leaving.
193167 const handlePopState = ( ) => {
194168 const leavingPath = lastPathRef . current ;
195169 _scrollPositions [ leavingPath ] = { x : window . scrollX , y : window . scrollY } ;
@@ -205,5 +179,29 @@ export function ScrollRestoration({}: ScrollRestorationProps): null {
205179 } ;
206180 } , [ ] ) ;
207181
182+ // After every render, restore scroll if a saved position exists for
183+ // the current pathname. The position is NOT deleted — it's kept alive
184+ // so Preact's render commits during navigation don't lose it.
185+ // It will be overwritten naturally when the user navigates away.
186+ React . useEffect ( ( ) => {
187+ const key = window . location . pathname ;
188+ const pos = _scrollPositions [ key ] ;
189+ if ( pos ) {
190+ // Retry across animation frames — Preact may perform multiple
191+ // render commits that reset scroll.
192+ let remaining = 10 ;
193+ const tryRestore = ( ) => {
194+ window . scrollTo ( pos . x , pos . y ) ;
195+ if (
196+ ( window . scrollY !== pos . y || window . scrollX !== pos . x ) &&
197+ -- remaining > 0
198+ ) {
199+ requestAnimationFrame ( tryRestore ) ;
200+ }
201+ } ;
202+ requestAnimationFrame ( tryRestore ) ;
203+ }
204+ } ) ;
205+
208206 return null ;
209207}
0 commit comments