Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/reference/components.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
::: reactpy_router

options:
members: ["route", "link", "navigate"]
members: ["route", "link", "navigate", "scroll_restoration"]
89 changes: 88 additions & 1 deletion src/js/src/components.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, { x: number; y: number }> = {};

/**
* 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;
}
2 changes: 1 addition & 1 deletion src/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { bind, History, Link, Navigate } from "./components";
export { bind, History, Link, Navigate, ScrollRestoration } from "./components";
2 changes: 2 additions & 0 deletions src/js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ export interface NavigateProps {
to: string | number;
replace?: boolean;
}

export interface ScrollRestorationProps {}
3 changes: 2 additions & 1 deletion src/reactpy_router/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -11,6 +11,7 @@
"link",
"navigate",
"route",
"scroll_restoration",
"use_params",
"use_search_params",
)
36 changes: 36 additions & 0 deletions src/reactpy_router/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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 ``<div>``.

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,
)
2 changes: 1 addition & 1 deletion src/reactpy_router/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 90 additions & 1 deletion tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Loading