diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f62237..25cd903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Don't forget to remove deprecated code on each major release! ### Added +- 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. ### Changed diff --git a/src/js/src/components.ts b/src/js/src/components.ts index a5affbe..061cb9e 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -100,12 +100,19 @@ export function Navigate({ replace = false, }: NavigateProps): null { React.useEffect(() => { - if (replace) { - replaceState(to); + if (typeof to === "number") { + // Relative history navigation (e.g. go back / go forward). + // The resulting popstate event is picked up by the History + // component, so no explicit callback is needed here. + window.history.go(to); } else { - pushState(to); + if (replace) { + replaceState(to); + } else { + pushState(to); + } + onNavigateCallback(createLocationObject()); } - onNavigateCallback(createLocationObject()); return () => {}; }, []); diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 13eb275..b060641 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -14,6 +14,6 @@ export interface LinkProps { export interface NavigateProps { onNavigateCallback: (location: ReactPyLocation) => void; - to: string; + to: string | number; replace?: boolean; } diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 95f0340..7235ad2 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -83,16 +83,18 @@ def route(path: str, element: Any | None, *routes: Route) -> Route: return Route(path, element, routes) -def navigate(to: str, replace: bool = False, key: Key | None = None) -> Component: +def navigate(to: str | int, replace: bool = False, key: Key | None = None) -> Component: """ Navigate to a specified URL. This function changes the browser's current URL when it is rendered. Args: - to: The target URL to navigate to. + to: The target URL to navigate to, or an integer indicating the relative + position in the browser's history stack (e.g., ``-1`` to go back, + ``1`` to go forward). See `History.go `_. replace: If True, the current history entry will be replaced \ - with the new URL. Defaults to False. + with the new URL. Ignored when ``to`` is an integer. Defaults to False. Returns: The component responsible for navigation. @@ -101,15 +103,21 @@ def navigate(to: str, replace: bool = False, key: Key | None = None) -> Componen @component -def _navigate(to: str, replace: bool = False) -> VdomDict | None: +def _navigate(to: str | int, replace: bool = False) -> VdomDict | None: location = use_connection().location set_location = _use_route_state().set_location - new_path = to.split("?", 1)[0] def on_navigate_callback(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - if location.path != new_path: + if isinstance(to, int): + # Integer navigation (go back/forward) — always delegate to JS; + # the resulting popstate event is handled by the History component. return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) + if isinstance(to, str): + new_path = to.split("?", 1)[0] + if location.path != new_path: + return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) + return None diff --git a/tests/test_router.py b/tests/test_router.py index 9349735..0d13e91 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -355,3 +355,110 @@ def sample(): await display.page.wait_for_selector("#nav-a") await display.page.go_back() await display.page.wait_for_selector("#root-a") + + +async def test_navigate_component_go_back(display: DisplayFixture): + """Navigate back using navigate(-1).""" + + @component + def nav_btn(): + nav, set_nav = use_state("") + + if nav: + return navigate(nav) + + return html.button( + {"onClick": lambda _: set_nav("/a"), "id": "nav-to-a"}, + "Go to A", + ) + + @component + def back_btn(): + delta, set_delta = use_state("") + + if isinstance(delta, int): + return navigate(delta) + + return html.button( + {"onClick": lambda _: set_delta(-1), "id": "go-back"}, + "Go back", + ) + + @component + def sample(): + return browser_router( + route("/", nav_btn()), + route("/a", back_btn()), + ) + + await display.show(sample) + + # Navigate to /a + await display.page.wait_for_selector("#nav-to-a") + await display.page.click("#nav-to-a") + + # Wait for the go-back button to appear on route /a + await display.page.wait_for_selector("#go-back") + assert await display.page.text_content("#go-back") == "Go back" + + # Go back using navigate(-1) + await display.page.click("#go-back") + + # Verify we're back at the root route + await display.page.wait_for_selector("#nav-to-a") + assert await display.page.text_content("#nav-to-a") == "Go to A" + + +async def test_navigate_component_go_forward(display: DisplayFixture): + """Navigate forward using navigate(1).""" + + @component + def forward_btn(): + delta, set_delta = use_state("") + + if isinstance(delta, int): + return navigate(delta) + + return html.button( + {"onClick": lambda _: set_delta(1), "id": "go-forward"}, + "Go forward", + ) + + @component + def back_btn(): + delta, set_delta = use_state("") + + if isinstance(delta, int): + return navigate(delta) + + return html.button( + {"onClick": lambda _: set_delta(-1), "id": "go-back"}, + "Go back", + ) + + @component + def sample(): + return browser_router( + route("/", forward_btn()), + route("/a", back_btn()), + ) + + await display.show(sample) + + # Navigate to /a via full page load (builds forward history entry) + await display.goto("/a") + + # Go back to / via navigate(-1) + await display.page.wait_for_selector("#go-back") + await display.page.click("#go-back") + + # Should now be at / with the "Go forward" button + await display.page.wait_for_selector("#go-forward") + assert await display.page.text_content("#go-forward") == "Go forward" + + # Go forward to /a via navigate(1) + await display.page.click("#go-forward") + + # Verify we're back at /a + await display.page.wait_for_selector("#go-back") + assert await display.page.text_content("#go-back") == "Go back"