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..918cc67 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 } from "./types"; +import { + HistoryProps, + LinkProps, + NavigateProps, + ScrollRestorationProps, +} from "./types"; /** * Interface used to bind a ReactPy node to React. @@ -118,3 +123,85 @@ 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. + * + * 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(() => { + window.history.scrollRestoration = "manual"; + + const originalPushState = window.history.pushState.bind(window.history); + window.history.pushState = (data, unused, url) => { + const key = window.location.pathname; + _scrollPositions[key] = { x: window.scrollX, y: window.scrollY }; + originalPushState(data, unused, url); + lastPathRef.current = window.location.pathname; + }; + + 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 }; + originalReplaceState(data, unused, url); + lastPathRef.current = window.location.pathname; + }; + + const handlePopState = () => { + const leavingPath = lastPathRef.current; + _scrollPositions[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 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/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..a149215 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": {"min-height": "100vh"}}, + ScrollRestoration({}), + *children, + ) 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 diff --git a/tests/test_router.py b/tests/test_router.py index 0d13e91..3e5af19 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,92 @@ 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") + + # 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, + )