From f048639d20824ca5c726066106c0f356ed3d291f Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 05:08:00 -0700 Subject: [PATCH 1/5] feat: allow navigate component to accept integers for go back/forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `navigate()` component now accepts `str | int` for the `to` parameter. When an integer is provided, the JS-side uses `history.go(to)` to perform relative navigation in the browser's history stack (e.g. `navigate(-1)` to go back, `navigate(1)` to go forward). Key changes: - Python: `navigate(to: str | int, ...)` — integer paths bypass path-matching logic and always render the JS Navigate component - JS: `Navigate` component checks `typeof to === "number"` and calls `window.history.go(to)`; the resulting popstate event is handled by the existing History component - JS: `NavigateProps.to` type updated to `string | number` - Tests: Added `test_navigate_component_go_back` and `test_navigate_component_go_forward` E2E tests Closes #52 --- CHANGELOG.md | 1 + src/js/src/components.ts | 15 +++-- src/js/src/types.ts | 2 +- src/reactpy_router/components.py | 17 ++++-- tests/test_router.py | 94 ++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 10 deletions(-) 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..4f5968d 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,14 +103,19 @@ 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 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}) + + new_path = to.split("?", 1)[0] if location.path != new_path: return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) diff --git a/tests/test_router.py b/tests/test_router.py index 9349735..7d44f92 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -355,3 +355,97 @@ 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("") + + return html.button( + {"onClick": lambda _: set_nav("/a")}, + navigate(nav) if nav else "Go to A", + ) + + @component + def back_btn(): + delta, set_delta = use_state("") + + return html.button( + {"onClick": lambda _: set_delta(-1)}, + navigate(delta) if isinstance(delta, int) else "Go back", + ) + + @component + def sample(): + return browser_router( + route("/", nav_btn()), + route("/a", back_btn()), + ) + + await display.show(sample) + + # Navigate to /a using the link + btn = await display.page.wait_for_selector("button") + assert await btn.text_content() == "Go to A" + await btn.click() + + # Go back using navigate(-1) + btn = await display.page.wait_for_selector("button") + assert await btn.text_content() == "Go back" + await btn.click() + + # Verify we're back at the root route + btn = await display.page.wait_for_selector("button") + assert await btn.text_content() == "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("") + + return html.button( + {"onClick": lambda _: set_delta(1), "id": "go-forward"}, + navigate(delta) if isinstance(delta, int) else "Go forward", + ) + + @component + def back_btn(): + delta, set_delta = use_state("") + + return html.button( + {"onClick": lambda _: set_delta(-1), "id": "go-back"}, + navigate(delta) if isinstance(delta, int) else "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") + await display.page.wait_for_selector("#go-back") + + # Go back to / via navigate(-1) + 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" From 79692e96e04717c1b6f8aefb7ecec5107c01e13e Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 05:16:33 -0700 Subject: [PATCH 2/5] fix: use direct return pattern for navigate in tests to avoid empty button content When navigate() was rendered as a child of html.button(), the Navigate component's JS null-render left the button with empty text. Changed tests to return navigate() at the component level instead. --- tests/test_router.py | 45 ++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/tests/test_router.py b/tests/test_router.py index 7d44f92..0d13e91 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -364,18 +364,24 @@ async def test_navigate_component_go_back(display: DisplayFixture): def nav_btn(): nav, set_nav = use_state("") + if nav: + return navigate(nav) + return html.button( - {"onClick": lambda _: set_nav("/a")}, - navigate(nav) if nav else "Go to A", + {"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)}, - navigate(delta) if isinstance(delta, int) else "Go back", + {"onClick": lambda _: set_delta(-1), "id": "go-back"}, + "Go back", ) @component @@ -387,19 +393,20 @@ def sample(): await display.show(sample) - # Navigate to /a using the link - btn = await display.page.wait_for_selector("button") - assert await btn.text_content() == "Go to A" - await btn.click() + # 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) - btn = await display.page.wait_for_selector("button") - assert await btn.text_content() == "Go back" - await btn.click() + await display.page.click("#go-back") # Verify we're back at the root route - btn = await display.page.wait_for_selector("button") - assert await btn.text_content() == "Go to A" + 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): @@ -409,18 +416,24 @@ async def test_navigate_component_go_forward(display: DisplayFixture): 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"}, - navigate(delta) if isinstance(delta, int) else "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"}, - navigate(delta) if isinstance(delta, int) else "Go back", + "Go back", ) @component @@ -434,9 +447,9 @@ def sample(): # Navigate to /a via full page load (builds forward history entry) await display.goto("/a") - await display.page.wait_for_selector("#go-back") # 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 From ebd79bd47b694e82d5e4c6231de907180c68f69f Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 05:25:07 -0700 Subject: [PATCH 3/5] fix: guard against Python bool subclass of int in navigate component Python's bool is a subclass of int, so isinstance(True, int) is True. Add explicit not isinstance(to, bool) guard to prevent booleans from being misinterpreted as relative history navigation. --- src/reactpy_router/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 4f5968d..829bfe4 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -110,7 +110,7 @@ def _navigate(to: str | int, replace: bool = False) -> VdomDict | None: def on_navigate_callback(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - if isinstance(to, int): + if isinstance(to, int) and not isinstance(to, bool): # 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}) From 13ccbe4e3a4e4e782b54837ce1e6d3f8ad44ca80 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 05:27:27 -0700 Subject: [PATCH 4/5] fix: use strict int type check to satisfy static type checker Pyright correctly flags that `isinstance(to, bool)` doesn't narrow the `str | int` union since `bool` is a subclass of `int`. Use `type(to) is int` for a strict exact-type check that both satisfies the type checker and correctly excludes booleans. --- src/reactpy_router/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 829bfe4..f691ef0 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -110,7 +110,7 @@ def _navigate(to: str | int, replace: bool = False) -> VdomDict | None: def on_navigate_callback(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - if isinstance(to, int) and not isinstance(to, bool): + if type(to) is 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}) From 824e5fc502cc2d995736d44aed75c39b0bcbe3ef Mon Sep 17 00:00:00 2001 From: Archmonger Date: Tue, 21 Jul 2026 05:33:34 -0700 Subject: [PATCH 5/5] fix: add isinstance(to, str) guard for pyright type narrowing Pyright cannot narrow to str after isinstance(to, int) since bool is a subclass of int. Wrapping the string-only code path in an explicit isinstance(to, str) check resolves the type error. --- src/reactpy_router/components.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index f691ef0..7235ad2 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -110,13 +110,14 @@ def _navigate(to: str | int, replace: bool = False) -> VdomDict | None: def on_navigate_callback(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - if type(to) is int: + 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}) - new_path = to.split("?", 1)[0] - if location.path != new_path: - 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