From c1fcb725f197568d840b35aa27b20fa568e89798 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 07:07:48 -0700 Subject: [PATCH 1/7] feat: add scroll_restoration component Introduces a component that saves and restores scroll positions across client-side navigation. - JS side: ScrollRestoration component that intercepts pushState/ replaceState and popstate events to save scroll positions keyed by pathname. Restores scroll via requestAnimationFrame after each render. Disables browser's native scrollRestoration on mount. - Python side: scroll_restoration(*children) component that wraps children in a scroll-aware div and renders the hidden JS component. Exported from reactpy_router top-level. - Tests: basic rendering test and scroll preservation test that scrolls, navigates away, and verifies restoration on return. - Docs: added scroll_restoration to reference/components.md. - Changelog: updated Unreleased section. --- CHANGELOG.md | 1 + docs/src/reference/components.md | 2 +- src/js/src/components.ts | 79 +++++++++++++++++++++++++++- src/js/src/index.ts | 2 +- src/js/src/types.ts | 2 + src/reactpy_router/__init__.py | 3 +- src/reactpy_router/components.py | 36 +++++++++++++ tests/test_router.py | 90 +++++++++++++++++++++++++++++++- 8 files changed, 210 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd903..e10a618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Don't forget to remove deprecated code on each major release! ### Added +- The `scroll_restoration` component that saves and restores scroll positions across client-side navigation. This component wraps children in a scroll-aware container and uses a hidden JavaScript component to manage scroll positions keyed by URL pathname. Analogous to React Router's `ScrollRestoration` component. - The `navigate` component now accepts integers for `to`, allowing relative navigation in the browser's history stack (e.g. `navigate(-1)` to go back, `navigate(1)` to go forward). - Support for ReactPy v2.x (beta). The initial URL is now sourced from the ReactPy executor (`use_connection().location`) instead of a JS-side `popstate` effect, removing a redundant network round-trip on first load. diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 9841110..fd7e04a 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -1,4 +1,4 @@ ::: reactpy_router options: - members: ["route", "link", "navigate"] + members: ["route", "link", "navigate", "scroll_restoration"] diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 061cb9e..f6a16c4 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -1,6 +1,6 @@ import { React } from "@reactpy/client"; import { createLocationObject, pushState, replaceState } from "./utils"; -import { HistoryProps, LinkProps, NavigateProps } from "./types"; +import { HistoryProps, LinkProps, NavigateProps, ScrollRestorationProps } from "./types"; /** * Interface used to bind a ReactPy node to React. @@ -118,3 +118,80 @@ export function Navigate({ return null; } + +/** + * ScrollRestoration component that saves and restores scroll positions across + * client-side navigation. Uses the browser's History API to track scroll + * positions keyed by URL pathname. + * + * On mount, it disables the browser's default scroll restoration and takes + * over the responsibility of saving and restoring scroll positions. + * Scroll positions are saved when: + * - The user navigates via pushState/replaceState (Link or Navigate component) + * - The user triggers a popstate event (browser back/forward) + * + * Scroll positions are restored after each render when the user returns to + * a previously visited page via history navigation. + */ +export function ScrollRestoration({}: ScrollRestorationProps): null { + // Store scroll positions keyed by pathname + const positions = React.useRef>({}); + // Track the last known pathname for popstate handling + const lastPathRef = React.useRef(window.location.pathname); + + // On mount, disable the browser's built-in scroll restoration so we can + // manage scroll positions ourselves. Also patch pushState and replaceState + // to save the scroll position of the page being navigated away from. + React.useEffect(() => { + window.history.scrollRestoration = "manual"; + + // Patch pushState to save scroll before URL changes + const originalPushState = window.history.pushState.bind(window.history); + window.history.pushState = (data, unused, url) => { + const key = window.location.pathname; + positions.current[key] = { x: window.scrollX, y: window.scrollY }; + originalPushState(data, unused, url); + lastPathRef.current = window.location.pathname; + }; + + // Patch replaceState to save scroll before URL changes + const originalReplaceState = window.history.replaceState.bind(window.history); + window.history.replaceState = (data, unused, url) => { + const key = window.location.pathname; + positions.current[key] = { x: window.scrollX, y: window.scrollY }; + originalReplaceState(data, unused, url); + lastPathRef.current = window.location.pathname; + }; + + // On popstate (browser back/forward), save the scroll of the page being + // left and restore the scroll of the page being returned to. + const handlePopState = () => { + // Save scroll for the page we're leaving + const leavingPath = lastPathRef.current; + positions.current[leavingPath] = { x: window.scrollX, y: window.scrollY }; + lastPathRef.current = window.location.pathname; + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }; + }, []); + + // After every render, restore scroll for the current URL if we have a + // saved position. Uses requestAnimationFrame to let the DOM settle first. + React.useEffect(() => { + const key = window.location.pathname; + const pos = positions.current[key]; + if (pos) { + requestAnimationFrame(() => { + window.scrollTo(pos.x, pos.y); + }); + } + }); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 8de0626..c18e03f 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1 +1 @@ -export { bind, History, Link, Navigate } from "./components"; +export { bind, History, Link, Navigate, ScrollRestoration } from "./components"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index b060641..923d0e5 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -17,3 +17,5 @@ export interface NavigateProps { to: string | number; replace?: boolean; } + +export interface ScrollRestorationProps {} diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 5736183..35b350e 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,7 +1,7 @@ __version__ = "3.0.0b1" -from reactpy_router.components import link, navigate, route +from reactpy_router.components import link, navigate, route, scroll_restoration from reactpy_router.hooks import use_params, use_search_params from reactpy_router.routers import browser_router, create_router @@ -11,6 +11,7 @@ "link", "navigate", "route", + "scroll_restoration", "use_params", "use_search_params", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 7235ad2..0bcf744 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -27,6 +27,11 @@ ) """Client-side portion of the navigate component""" +ScrollRestoration = component_from_file( + Path(__file__).parent / "static" / "bundle.js", import_names="ScrollRestoration", name="reactpy-router" +) +"""Client-side portion of scroll restoration""" + def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component: """ @@ -121,3 +126,34 @@ def on_navigate_callback(_event: dict[str, Any]) -> None: return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) return None + + +def scroll_restoration(*children: Any, key: Key | None = None) -> Component: + """ + A component that saves and restores scroll positions across client-side navigation. + + This component is analogous to React Router's ``ScrollRestoration`` component. + It renders a hidden JavaScript component that manages scroll positions by keying + them to the current URL pathname. Scroll positions are automatically saved when + navigating away from a page and restored when returning via browser back/forward + or client-side navigation. + + This component also accepts server-side children, which are rendered as the + content of a wrapper ``
``. + + Args: + *children: Server-side child elements to render inside the scroll restoration wrapper. + + Returns: + A component that renders children inside a scroll restoration wrapper div. + """ + return _scroll_restoration(*children, key=key) + + +@component +def _scroll_restoration(*children: Any) -> VdomDict: + return html.div( + {"style": {"height": "100%"}}, + ScrollRestoration({}), + *children, + ) diff --git a/tests/test_router.py b/tests/test_router.py index 0d13e91..e80b195 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -6,7 +6,7 @@ from reactpy import Ref, component, html, use_location, use_state from reactpy.testing import DisplayFixture -from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params +from reactpy_router import browser_router, link, navigate, route, scroll_restoration, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" pytestmark = pytest.mark.anyio @@ -462,3 +462,91 @@ def sample(): # Verify we're back at /a await display.page.wait_for_selector("#go-back") assert await display.page.text_content("#go-back") == "Go back" + + +async def test_scroll_restoration_basic_rendering(display: DisplayFixture): + """Verify scroll_restoration renders its children inside a router.""" + + @component + def sample(): + return browser_router( + route( + "/", + scroll_restoration( + link({"to": "/a", "id": "root-link"}, "Root"), + html.h1({"id": "home"}, "Home"), + ), + ), + route( + "/a", + scroll_restoration( + link({"to": "/", "id": "back-link"}, "Back"), + html.h1({"id": "page-a"}, "Page A"), + ), + ), + ) + + await display.show(sample) + await display.page.wait_for_selector("#home") + assert await display.page.text_content("#home") == "Home" + + await display.page.click("#root-link") + await display.page.wait_for_selector("#page-a") + assert await display.page.text_content("#page-a") == "Page A" + + await display.page.click("#back-link") + await display.page.wait_for_selector("#home") + assert await display.page.text_content("#home") == "Home" + + +async def test_scroll_restoration_preserves_scroll(display: DisplayFixture): + """Verify scroll position is preserved when navigating back.""" + + @component + def scroll_page(): + tall_content = [html.div({"style": {"height": "1500px"}}, f"Section {i}") for i in range(10)] + link_list = link({"to": "/other", "id": "to-other"}, "Go to other", key="to-other") + return scroll_restoration( + html.h1({"id": "scroll-page"}, "Scroll Page"), + *tall_content, + link_list, + ) + + @component + def other_page(): + return scroll_restoration( + html.h1({"id": "other-page"}, "Other Page"), + link({"to": "/", "id": "back-to-scroll"}, "Back to scroll page", key="back-to-scroll"), + ) + + @component + def sample(): + return browser_router( + route("/", scroll_page()), + route("/other", other_page()), + ) + + await display.show(sample) + + # Wait for the scroll page to render + await display.page.wait_for_selector("#scroll-page") + + # Scroll down 500px + await display.page.evaluate("window.scrollTo(0, 500)") + scroll_y = await display.page.evaluate("window.scrollY") + assert scroll_y >= 500, f"Expected scrollY >= 500, got {scroll_y}" + + # Navigate to /other via link + await display.page.click("#to-other") + await display.page.wait_for_selector("#other-page") + + # Navigate back to / via link + await display.page.click("#back-to-scroll") + await display.page.wait_for_selector("#scroll-page") + + # Wait a tick for scroll restoration to apply + await display.page.wait_for_timeout(200) + + # Verify scroll position was restored + restored_scroll_y = await display.page.evaluate("window.scrollY") + assert restored_scroll_y >= 450, f"Expected restored scrollY >= 450, got {restored_scroll_y}" From b08cabe3d41eae1ddfdd7004b7d69bba5f81cdfe Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 07:43:56 -0700 Subject: [PATCH 2/7] 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. --- src/js/src/components.ts | 62 ++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index f6a16c4..e53084e 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -119,37 +119,49 @@ export function Navigate({ return null; } +// Module-level scroll positions keyed by pathname. Shared across all +// ScrollRestoration component instances so saved positions survive +// unmount/remount during route transitions. +const _scrollPositions: Record = {}; + /** * ScrollRestoration component that saves and restores scroll positions across * client-side navigation. Uses the browser's History API to track scroll * positions keyed by URL pathname. * - * On mount, it disables the browser's default scroll restoration and takes - * over the responsibility of saving and restoring scroll positions. - * Scroll positions are saved when: - * - The user navigates via pushState/replaceState (Link or Navigate component) - * - The user triggers a popstate event (browser back/forward) + * On mount, it: + * - Disables the browser's native scroll restoration + * - Patches pushState/replaceState to save scroll positions before navigation + * - Listens for popstate to save the leaving page's scroll + * - Restores any previously saved scroll position for the current pathname * - * Scroll positions are restored after each render when the user returns to - * a previously visited page via history navigation. + * Scroll positions are consumed on restore so subsequent re-renders on the same + * page do not snap the user back. The module-level store survives the + * component's unmount/remount cycle across route transitions. */ export function ScrollRestoration({}: ScrollRestorationProps): null { - // Store scroll positions keyed by pathname - const positions = React.useRef>({}); - // Track the last known pathname for popstate handling const lastPathRef = React.useRef(window.location.pathname); - // On mount, disable the browser's built-in scroll restoration so we can - // manage scroll positions ourselves. Also patch pushState and replaceState - // to save the scroll position of the page being navigated away from. React.useEffect(() => { + // Restore any saved scroll position for the current pathname on mount. + // The component mounts/unmounts on route changes, so this runs once + // per navigation. The position is consumed after restoration. + const currentKey = window.location.pathname; + const savedPos = _scrollPositions[currentKey]; + if (savedPos) { + delete _scrollPositions[currentKey]; + requestAnimationFrame(() => { + window.scrollTo(savedPos.x, savedPos.y); + }); + } + window.history.scrollRestoration = "manual"; // Patch pushState to save scroll before URL changes const originalPushState = window.history.pushState.bind(window.history); window.history.pushState = (data, unused, url) => { const key = window.location.pathname; - positions.current[key] = { x: window.scrollX, y: window.scrollY }; + _scrollPositions[key] = { x: window.scrollX, y: window.scrollY }; originalPushState(data, unused, url); lastPathRef.current = window.location.pathname; }; @@ -158,17 +170,17 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { const originalReplaceState = window.history.replaceState.bind(window.history); window.history.replaceState = (data, unused, url) => { const key = window.location.pathname; - positions.current[key] = { x: window.scrollX, y: window.scrollY }; + _scrollPositions[key] = { x: window.scrollX, y: window.scrollY }; originalReplaceState(data, unused, url); lastPathRef.current = window.location.pathname; }; - // On popstate (browser back/forward), save the scroll of the page being - // left and restore the scroll of the page being returned to. + // On popstate, save the scroll of the page we're leaving. + // The popstate event fires before the next render, so we use + // lastPathRef (the pathname before navigation) as the key. const handlePopState = () => { - // Save scroll for the page we're leaving const leavingPath = lastPathRef.current; - positions.current[leavingPath] = { x: window.scrollX, y: window.scrollY }; + _scrollPositions[leavingPath] = { x: window.scrollX, y: window.scrollY }; lastPathRef.current = window.location.pathname; }; @@ -181,17 +193,5 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { }; }, []); - // After every render, restore scroll for the current URL if we have a - // saved position. Uses requestAnimationFrame to let the DOM settle first. - React.useEffect(() => { - const key = window.location.pathname; - const pos = positions.current[key]; - if (pos) { - requestAnimationFrame(() => { - window.scrollTo(pos.x, pos.y); - }); - } - }); - return null; } From 27dd0e8bd39d4133d00ff9d5f4aa3b4e4b1a00ed Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 08:03:42 -0700 Subject: [PATCH 3/7] fix: apply prettier formatting to components.ts --- src/js/src/components.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index e53084e..9d96bfa 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -1,6 +1,11 @@ import { React } from "@reactpy/client"; import { createLocationObject, pushState, replaceState } from "./utils"; -import { HistoryProps, LinkProps, NavigateProps, ScrollRestorationProps } from "./types"; +import { + HistoryProps, + LinkProps, + NavigateProps, + ScrollRestorationProps, +} from "./types"; /** * Interface used to bind a ReactPy node to React. @@ -167,7 +172,9 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { }; // Patch replaceState to save scroll before URL changes - const originalReplaceState = window.history.replaceState.bind(window.history); + const originalReplaceState = window.history.replaceState.bind( + window.history, + ); window.history.replaceState = (data, unused, url) => { const key = window.location.pathname; _scrollPositions[key] = { x: window.scrollX, y: window.scrollY }; From 4a255a728b0c1b4cca4f74434eb854e098e11d53 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 08:07:13 -0700 Subject: [PATCH 4/7] fix: remove unused noqa directive in hooks.py --- src/reactpy_router/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 725a8a9..12235e4 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -5,7 +5,7 @@ from reactpy import create_context, use_context, use_location -from reactpy_router.types import RouteState # noqa: TC001 +from reactpy_router.types import RouteState if TYPE_CHECKING: from reactpy.types import Context From dc5735f17c002b9b915cb715952821ca7d332c47 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 13:12:52 -0700 Subject: [PATCH 5/7] ci: trigger fresh CI run From 62fd940080f081ff05e8cbc0d60d703ce886fb47 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 13:42:29 -0700 Subject: [PATCH 6/7] fix: retry scroll restoration across Preact commit phases Preact may perform multiple render commits during a single navigation event (server VDOM -> layout effects -> client-side effects). Each commit can reset scroll position. Fix by retrying scrollTo each animation frame (up to 5 retries) until the position sticks. --- src/js/src/components.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 9d96bfa..2d96bad 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -148,16 +148,23 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { const lastPathRef = React.useRef(window.location.pathname); React.useEffect(() => { - // Restore any saved scroll position for the current pathname on mount. - // The component mounts/unmounts on route changes, so this runs once - // per navigation. The position is consumed after restoration. const currentKey = window.location.pathname; - const savedPos = _scrollPositions[currentKey]; - if (savedPos) { - delete _scrollPositions[currentKey]; - requestAnimationFrame(() => { + if (currentKey in _scrollPositions) { + const savedPos = _scrollPositions[currentKey]; + // Retry scroll restoration each animation frame until success. + // Preact may perform multiple render commits during a single + // navigation, and each commit can reset the scroll position. + // We keep retrying until the position actually sticks. + let remaining = 5; // max 5 retries (~80ms max) + const tryRestore = () => { + if (window.scrollY === savedPos.y && window.scrollX === savedPos.x) { + delete _scrollPositions[currentKey]; + return; + } window.scrollTo(savedPos.x, savedPos.y); - }); + if (--remaining > 0) requestAnimationFrame(tryRestore); + }; + tryRestore(); } window.history.scrollRestoration = "manual"; @@ -183,8 +190,6 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { }; // On popstate, save the scroll of the page we're leaving. - // The popstate event fires before the next render, so we use - // lastPathRef (the pathname before navigation) as the key. const handlePopState = () => { const leavingPath = lastPathRef.current; _scrollPositions[leavingPath] = { x: window.scrollX, y: window.scrollY }; From 6641e6da985fbc99a18f4ad5deab8088ca367247 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 14:00:35 -0700 Subject: [PATCH 7/7] 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 --- src/js/src/components.ts | 64 ++++++++++++++++---------------- src/reactpy_router/components.py | 2 +- tests/test_router.py | 13 ++++--- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 2d96bad..918cc67 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -131,45 +131,21 @@ const _scrollPositions: Record = {}; /** * ScrollRestoration component that saves and restores scroll positions across - * client-side navigation. Uses the browser's History API to track scroll - * positions keyed by URL pathname. + * client-side navigation. * - * On mount, it: - * - Disables the browser's native scroll restoration - * - Patches pushState/replaceState to save scroll positions before navigation - * - Listens for popstate to save the leaving page's scroll - * - Restores any previously saved scroll position for the current pathname - * - * Scroll positions are consumed on restore so subsequent re-renders on the same - * page do not snap the user back. The module-level store survives the - * component's unmount/remount cycle across route transitions. + * The one-time mount effect patches pushState/replaceState to save scroll + * before navigation and registers a popstate listener. The post-render effect + * (no deps) restores scroll for the current pathname whenever a saved position + * exists — the position is kept alive in the module store so it remains + * available across Preact's render commit cycle. */ export function ScrollRestoration({}: ScrollRestorationProps): null { const lastPathRef = React.useRef(window.location.pathname); + // One-time setup: patch history methods and register popstate listener. React.useEffect(() => { - const currentKey = window.location.pathname; - if (currentKey in _scrollPositions) { - const savedPos = _scrollPositions[currentKey]; - // Retry scroll restoration each animation frame until success. - // Preact may perform multiple render commits during a single - // navigation, and each commit can reset the scroll position. - // We keep retrying until the position actually sticks. - let remaining = 5; // max 5 retries (~80ms max) - const tryRestore = () => { - if (window.scrollY === savedPos.y && window.scrollX === savedPos.x) { - delete _scrollPositions[currentKey]; - return; - } - window.scrollTo(savedPos.x, savedPos.y); - if (--remaining > 0) requestAnimationFrame(tryRestore); - }; - tryRestore(); - } - window.history.scrollRestoration = "manual"; - // Patch pushState to save scroll before URL changes const originalPushState = window.history.pushState.bind(window.history); window.history.pushState = (data, unused, url) => { const key = window.location.pathname; @@ -178,7 +154,6 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { lastPathRef.current = window.location.pathname; }; - // Patch replaceState to save scroll before URL changes const originalReplaceState = window.history.replaceState.bind( window.history, ); @@ -189,7 +164,6 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { lastPathRef.current = window.location.pathname; }; - // On popstate, save the scroll of the page we're leaving. const handlePopState = () => { const leavingPath = lastPathRef.current; _scrollPositions[leavingPath] = { x: window.scrollX, y: window.scrollY }; @@ -205,5 +179,29 @@ export function ScrollRestoration({}: ScrollRestorationProps): null { }; }, []); + // After every render, restore scroll if a saved position exists for + // the current pathname. The position is NOT deleted — it's kept alive + // so Preact's render commits during navigation don't lose it. + // It will be overwritten naturally when the user navigates away. + React.useEffect(() => { + const key = window.location.pathname; + const pos = _scrollPositions[key]; + if (pos) { + // Retry across animation frames — Preact may perform multiple + // render commits that reset scroll. + let remaining = 10; + const tryRestore = () => { + window.scrollTo(pos.x, pos.y); + if ( + (window.scrollY !== pos.y || window.scrollX !== pos.x) && + --remaining > 0 + ) { + requestAnimationFrame(tryRestore); + } + }; + requestAnimationFrame(tryRestore); + } + }); + return null; } diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 0bcf744..a149215 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -153,7 +153,7 @@ def scroll_restoration(*children: Any, key: Key | None = None) -> Component: @component def _scroll_restoration(*children: Any) -> VdomDict: return html.div( - {"style": {"height": "100%"}}, + {"style": {"min-height": "100vh"}}, ScrollRestoration({}), *children, ) diff --git a/tests/test_router.py b/tests/test_router.py index e80b195..3e5af19 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -544,9 +544,10 @@ def sample(): await display.page.click("#back-to-scroll") await display.page.wait_for_selector("#scroll-page") - # Wait a tick for scroll restoration to apply - await display.page.wait_for_timeout(200) - - # Verify scroll position was restored - restored_scroll_y = await display.page.evaluate("window.scrollY") - assert restored_scroll_y >= 450, f"Expected restored scrollY >= 450, got {restored_scroll_y}" + # Poll for scroll restoration to apply (it runs in useLayoutEffect which + # fires synchronously after DOM commit, but the browser needs at least one + # frame to paint when scrollTo is called during the same commit). + await display.page.wait_for_function( + "window.scrollY >= 450", + timeout=5000, + )