Skip to content

Commit 6641e6d

Browse files
committed
fix: run scroll restoration on every render cycle
The ScrollRestoration component is reused by Preact across route navigations (same component type at the same VDOM position). The mount effect (with [] deps) only fires once, so the restoration logic must run in a post-render effect (no deps) to detect pathname changes. Key changes: - Post-render effect restores scroll on every render if a saved position exists for the current pathname - Position is kept alive (not deleted) so Preact render commits during navigation don't lose it - Retry across rAF frames (up to 10) to survive Preact's multi-commit navigation cycle - Python: use 'min-height: 100vh' instead of 'height: 100%' to not constrain document height
1 parent 62fd940 commit 6641e6d

3 files changed

Lines changed: 39 additions & 40 deletions

File tree

src/js/src/components.ts

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
147142
export 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
}

src/reactpy_router/components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def scroll_restoration(*children: Any, key: Key | None = None) -> Component:
153153
@component
154154
def _scroll_restoration(*children: Any) -> VdomDict:
155155
return html.div(
156-
{"style": {"height": "100%"}},
156+
{"style": {"min-height": "100vh"}},
157157
ScrollRestoration({}),
158158
*children,
159159
)

tests/test_router.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -544,9 +544,10 @@ def sample():
544544
await display.page.click("#back-to-scroll")
545545
await display.page.wait_for_selector("#scroll-page")
546546

547-
# Wait a tick for scroll restoration to apply
548-
await display.page.wait_for_timeout(200)
549-
550-
# Verify scroll position was restored
551-
restored_scroll_y = await display.page.evaluate("window.scrollY")
552-
assert restored_scroll_y >= 450, f"Expected restored scrollY >= 450, got {restored_scroll_y}"
547+
# Poll for scroll restoration to apply (it runs in useLayoutEffect which
548+
# fires synchronously after DOM commit, but the browser needs at least one
549+
# frame to paint when scrollTo is called during the same commit).
550+
await display.page.wait_for_function(
551+
"window.scrollY >= 450",
552+
timeout=5000,
553+
)

0 commit comments

Comments
 (0)