Skip to content

Commit b08cabe

Browse files
committed
fix: use module-level scroll positions to survive unmount/remount
- Use module-level _scrollPositions dict instead of useRef so saved scroll positions persist across route transitions (component remounts). - Restore scroll on mount (useEffect[] with cleanup) rather than on every render, preventing scroll jumps on re-renders. - Consume (delete) scroll position after restoration so it fires once per navigation cycle.
1 parent c1fcb72 commit b08cabe

1 file changed

Lines changed: 31 additions & 31 deletions

File tree

src/js/src/components.ts

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -119,37 +119,49 @@ export function Navigate({
119119
return null;
120120
}
121121

122+
// Module-level scroll positions keyed by pathname. Shared across all
123+
// ScrollRestoration component instances so saved positions survive
124+
// unmount/remount during route transitions.
125+
const _scrollPositions: Record<string, { x: number; y: number }> = {};
126+
122127
/**
123128
* ScrollRestoration component that saves and restores scroll positions across
124129
* client-side navigation. Uses the browser's History API to track scroll
125130
* positions keyed by URL pathname.
126131
*
127-
* On mount, it disables the browser's default scroll restoration and takes
128-
* over the responsibility of saving and restoring scroll positions.
129-
* Scroll positions are saved when:
130-
* - The user navigates via pushState/replaceState (Link or Navigate component)
131-
* - The user triggers a popstate event (browser back/forward)
132+
* On mount, it:
133+
* - Disables the browser's native scroll restoration
134+
* - Patches pushState/replaceState to save scroll positions before navigation
135+
* - Listens for popstate to save the leaving page's scroll
136+
* - Restores any previously saved scroll position for the current pathname
132137
*
133-
* Scroll positions are restored after each render when the user returns to
134-
* a previously visited page via history navigation.
138+
* Scroll positions are consumed on restore so subsequent re-renders on the same
139+
* page do not snap the user back. The module-level store survives the
140+
* component's unmount/remount cycle across route transitions.
135141
*/
136142
export function ScrollRestoration({}: ScrollRestorationProps): null {
137-
// Store scroll positions keyed by pathname
138-
const positions = React.useRef<Record<string, { x: number; y: number }>>({});
139-
// Track the last known pathname for popstate handling
140143
const lastPathRef = React.useRef(window.location.pathname);
141144

142-
// On mount, disable the browser's built-in scroll restoration so we can
143-
// manage scroll positions ourselves. Also patch pushState and replaceState
144-
// to save the scroll position of the page being navigated away from.
145145
React.useEffect(() => {
146+
// Restore any saved scroll position for the current pathname on mount.
147+
// The component mounts/unmounts on route changes, so this runs once
148+
// per navigation. The position is consumed after restoration.
149+
const currentKey = window.location.pathname;
150+
const savedPos = _scrollPositions[currentKey];
151+
if (savedPos) {
152+
delete _scrollPositions[currentKey];
153+
requestAnimationFrame(() => {
154+
window.scrollTo(savedPos.x, savedPos.y);
155+
});
156+
}
157+
146158
window.history.scrollRestoration = "manual";
147159

148160
// Patch pushState to save scroll before URL changes
149161
const originalPushState = window.history.pushState.bind(window.history);
150162
window.history.pushState = (data, unused, url) => {
151163
const key = window.location.pathname;
152-
positions.current[key] = { x: window.scrollX, y: window.scrollY };
164+
_scrollPositions[key] = { x: window.scrollX, y: window.scrollY };
153165
originalPushState(data, unused, url);
154166
lastPathRef.current = window.location.pathname;
155167
};
@@ -158,17 +170,17 @@ export function ScrollRestoration({}: ScrollRestorationProps): null {
158170
const originalReplaceState = window.history.replaceState.bind(window.history);
159171
window.history.replaceState = (data, unused, url) => {
160172
const key = window.location.pathname;
161-
positions.current[key] = { x: window.scrollX, y: window.scrollY };
173+
_scrollPositions[key] = { x: window.scrollX, y: window.scrollY };
162174
originalReplaceState(data, unused, url);
163175
lastPathRef.current = window.location.pathname;
164176
};
165177

166-
// On popstate (browser back/forward), save the scroll of the page being
167-
// left and restore the scroll of the page being returned to.
178+
// On popstate, save the scroll of the page we're leaving.
179+
// The popstate event fires before the next render, so we use
180+
// lastPathRef (the pathname before navigation) as the key.
168181
const handlePopState = () => {
169-
// Save scroll for the page we're leaving
170182
const leavingPath = lastPathRef.current;
171-
positions.current[leavingPath] = { x: window.scrollX, y: window.scrollY };
183+
_scrollPositions[leavingPath] = { x: window.scrollX, y: window.scrollY };
172184
lastPathRef.current = window.location.pathname;
173185
};
174186

@@ -181,17 +193,5 @@ export function ScrollRestoration({}: ScrollRestorationProps): null {
181193
};
182194
}, []);
183195

184-
// After every render, restore scroll for the current URL if we have a
185-
// saved position. Uses requestAnimationFrame to let the DOM settle first.
186-
React.useEffect(() => {
187-
const key = window.location.pathname;
188-
const pos = positions.current[key];
189-
if (pos) {
190-
requestAnimationFrame(() => {
191-
window.scrollTo(pos.x, pos.y);
192-
});
193-
}
194-
});
195-
196196
return null;
197197
}

0 commit comments

Comments
 (0)