From a4e90aa447b6d04e5edf9b6107bd8e24106a2a9e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 02:39:45 -0700 Subject: [PATCH 01/27] Fix docs publishing --- .github/workflows/test-docs.yml | 1 - requirements/build-docs.txt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 7110bc4..df91de1 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -25,7 +25,6 @@ jobs: pip install -r requirements/build-docs.txt pip install -r requirements/check-types.txt pip install -r requirements/check-style.txt - pip install -e . - name: Check docs build run: | linkcheckMarkdown docs/ -v -r diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index 0d2bca2..f6561b3 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -9,3 +9,4 @@ mkdocs-minify-plugin mkdocs-section-index mike mkdocstrings[python] +. From 491027d14c825ffa70b4217a6bc73cb45331b676 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:58:38 -0700 Subject: [PATCH 02/27] functional navigate component --- src/js/src/index.js | 41 ++++++++++++++++++++++---------- src/reactpy_router/__init__.py | 3 ++- src/reactpy_router/components.py | 31 ++++++++++++++++++++++-- src/reactpy_router/routers.py | 2 +- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index 8ead7eb..819b237 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,13 +12,13 @@ export function bind(node) { }; } -export function History({ onHistoryChange }) { +export function History({ onHistoryChangeCallback }) { // Capture browser "history go back" action and tell the server about it // Note: Browsers do not allow us to detect "history go forward" actions. React.useEffect(() => { // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. const listener = () => { - onHistoryChange({ + onHistoryChangeCallback({ pathname: window.location.pathname, search: window.location.search, }); @@ -34,20 +34,20 @@ export function History({ onHistoryChange }) { // Tell the server about the URL during the initial page load // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug. // https://github.com/reactive-python/reactpy/pull/1224 - React.useEffect(() => { - onHistoryChange({ - pathname: window.location.pathname, - search: window.location.search, - }); - return () => {}; - }, []); + // React.useEffect(() => { + // onHistoryChange({ + // pathname: window.location.pathname, + // search: window.location.search, + // }); + // return () => {}; + // }, []); return null; } // FIXME: The Link component is unused due to a ReactPy core rendering bug // which causes duplicate rendering (and thus duplicate event listeners). // https://github.com/reactive-python/reactpy/pull/1224 -export function Link({ onClick, linkClass }) { +export function Link({ onClickCallback, linkClass }) { // This component is not the actual anchor link. // It is an event listener for the link component created by ReactPy. React.useEffect(() => { @@ -55,8 +55,8 @@ export function Link({ onClick, linkClass }) { const handleClick = (event) => { event.preventDefault(); let to = event.target.getAttribute("href"); - window.history.pushState({}, to, new URL(to, window.location)); - onClick({ + window.history.pushState(null, "", new URL(to, window.location)); + onClickCallback({ pathname: window.location.pathname, search: window.location.search, }); @@ -78,3 +78,20 @@ export function Link({ onClick, linkClass }) { }); return null; } + +export function Navigate({ onNavigateCallback, to, replace }) { + React.useEffect(() => { + if (replace) { + window.history.replaceState(null, "", new URL(to, window.location)); + } else { + window.history.pushState(null, "", new URL(to, window.location)); + } + onNavigateCallback({ + pathname: window.location.pathname, + search: window.location.search, + }); + return () => {}; + }, []); + + return null; +} diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index fa2781f..9f272c2 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.1.1" -from .components import link, route +from .components import link, navigate, route from .hooks import use_params, use_search_params from .routers import browser_router, create_router @@ -13,4 +13,5 @@ "browser_router", "use_params", "use_search_params", + "navigate", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 6c023d4..39f3654 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -26,6 +26,12 @@ ) """Client-side portion of link handling""" +Navigate = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("Navigate"), +) +"""Client-side portion of the navigate component""" + link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") @@ -93,11 +99,32 @@ def on_click(_event: dict[str, Any]) -> None: return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) - # def on_click(_event: dict[str, Any]) -> None: + # def on_click_callback(_event: dict[str, Any]) -> None: # set_location(Location(**_event)) - # return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string})) + # return html._(html.a(attrs, *children), Link({"onClickCallback": on_click_callback, "linkClass": uuid_string})) def route(path: str, element: Any | None, *routes: Route) -> Route: """Create a route with the given path, element, and child routes.""" return Route(path, element, routes) + + +def navigate(to: str, replace: bool = False) -> Component: + """A `navigate` element changes the current location when it is rendered.""" + return _navigate(to, replace) + + +@component +def _navigate(to: str, replace: bool = False) -> VdomDict | None: + """A `navigate` element changes the current location when it is rendered.""" + location = use_connection().location + set_location = _use_route_state().set_location + pathname = to.split("?", 1)[0] + + def on_navigate_callback(_event: dict[str, Any]) -> None: + set_location(Location(**_event)) + + if location.pathname != pathname: + return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) + + return None diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 25b72c1..4b46151 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -71,7 +71,7 @@ def on_history_change(event: dict[str, Any]) -> None: set_location(new_location) return ConnectionContext( - History({"onHistoryChange": on_history_change}), # type: ignore[return-value] + History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] *route_elements, value=Connection(old_conn.scope, location, old_conn.carrier), ) From cac297bdc81087046d8f4537afe849cfe8b356e5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:12:22 -0700 Subject: [PATCH 03/27] Better fix for key identity of route components --- src/reactpy_router/routers.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 4b46151..804cf89 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -4,13 +4,12 @@ from dataclasses import replace from logging import getLogger -from typing import Any, Iterator, Literal, Sequence +from typing import Any, Iterator, Literal, Sequence, cast from reactpy import component, use_memo, use_state from reactpy.backend.hooks import ConnectionContext, use_connection from reactpy.backend.types import Connection, Location -from reactpy.core.types import VdomDict -from reactpy.types import ComponentType +from reactpy.types import ComponentType, VdomDict from reactpy_router.components import History from reactpy_router.hooks import _route_state_context, _RouteState @@ -86,6 +85,18 @@ def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]: yield parent +def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any: + """Add a key to the VDOM or component on the current route, if it doesn't already have one.""" + element, _params = match + if hasattr(element, "render") and not element.key: + element = cast(ComponentType, element) + element.key = key + elif isinstance(element, dict) and not element.get("key", None): + element = cast(VdomDict, element) + element["key"] = key + return match + + def _match_route( compiled_routes: Sequence[CompiledRoute], location: Location, @@ -97,12 +108,14 @@ def _match_route( match = resolver.resolve(location.pathname) if match is not None: if select == "first": - return [match] + return [_add_route_key(match, resolver.key)] # Matching multiple routes is disabled since `react-router` no longer supports multiple # matches via the `Route` component. However, it's kept here to support future changes # or third-party routers. - matches.append(match) # pragma: no cover + # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as + # a key here, unless we begin throwing errors for duplicate routes. + matches.append(_add_route_key(match, resolver.key)) # pragma: no cover if not matches: _logger.debug("No matching route found for %s", location.pathname) From e730eea3698637517c6a8f18a8b9ef2eefbe5a58 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:13:19 -0700 Subject: [PATCH 04/27] Add tests for navigate component --- docs/src/reference/components.md | 2 +- tests/test_router.py | 57 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index f1cc570..9841110 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -1,4 +1,4 @@ ::: reactpy_router options: - members: ["route", "link"] + members: ["route", "link", "navigate"] diff --git a/tests/test_router.py b/tests/test_router.py index d6e0deb..095a9ee 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -2,10 +2,10 @@ from typing import Any from playwright.async_api._generated import Browser, Page -from reactpy import Ref, component, html, use_location +from reactpy import Ref, component, html, use_location, use_state from reactpy.testing import DisplayFixture -from reactpy_router import browser_router, link, route, use_params, use_search_params +from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. @@ -295,3 +295,56 @@ def sample(): browser_context = browser.contexts[0] new_page: Page = await browser_context.wait_for_event("page") await new_page.wait_for_selector("#a") + + +async def test_navigate_component(display: DisplayFixture): + @component + def navigate_btn(): + nav_url, set_nav_url = use_state("") + + return html.button( + {"onClick": lambda _: set_nav_url("/a")}, + navigate(nav_url) if nav_url else "Click to navigate", + ) + + @component + def sample(): + return browser_router( + route("/", navigate_btn()), + route("/a", html.h1({"id": "a"}, "A")), + ) + + await display.show(sample) + _button = await display.page.wait_for_selector("button") + await _button.click(delay=CLICK_DELAY) + await display.page.wait_for_selector("#a") + await display.page.go_back() + await display.page.wait_for_selector("button") + + +async def test_navigate_component_replace(display: DisplayFixture): + @component + def navigate_btn(to: str, replace: bool = False): + nav_url, set_nav_url = use_state("") + + return html.button( + {"onClick": lambda _: set_nav_url(to), "id": f"nav-{to.replace('/', '')}"}, + navigate(nav_url, replace) if nav_url else f"Navigate to {to}", + ) + + @component + def sample(): + return browser_router( + route("/", navigate_btn("/a")), + route("/a", navigate_btn("/b", replace=True)), + route("/b", html.h1({"id": "b"}, "B")), + ) + + await display.show(sample) + _button = await display.page.wait_for_selector("#nav-a") + await _button.click(delay=CLICK_DELAY) + _button = await display.page.wait_for_selector("#nav-b") + await _button.click(delay=CLICK_DELAY) + await display.page.wait_for_selector("#b") + await display.page.go_back() + await display.page.wait_for_selector("#nav-a") From 831aa206ab3fd1f52f16eee2225cdfdf8345f8f2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:28:35 -0700 Subject: [PATCH 05/27] Add comments to javascript --- src/js/src/index.js | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index 819b237..c59fea3 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,9 +12,19 @@ export function bind(node) { }; } +/** + * History component that captures browser "history go back" actions and notifies the server. + * + * @param {Object} props - The properties object. + * @param {Function} props.onHistoryChangeCallback - Callback function to notify the server about history changes. + * @returns {null} This component does not render any visible output. + * @description + * This component uses the `popstate` event to detect when the user navigates back in the browser history. + * It then calls the `onHistoryChangeCallback` with the current pathname and search parameters. + * Note: Browsers do not allow detection of "history go forward" actions. + * @see https://github.com/reactive-python/reactpy/pull/1224 + */ export function History({ onHistoryChangeCallback }) { - // Capture browser "history go back" action and tell the server about it - // Note: Browsers do not allow us to detect "history go forward" actions. React.useEffect(() => { // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. const listener = () => { @@ -32,8 +42,10 @@ export function History({ onHistoryChangeCallback }) { }); // Tell the server about the URL during the initial page load - // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug. + // FIXME: This code is commented out since it currently runs every time any component + // is mounted due to a ReactPy core rendering bug. // https://github.com/reactive-python/reactpy/pull/1224 + // React.useEffect(() => { // onHistoryChange({ // pathname: window.location.pathname, @@ -44,10 +56,20 @@ export function History({ onHistoryChangeCallback }) { return null; } -// FIXME: The Link component is unused due to a ReactPy core rendering bug -// which causes duplicate rendering (and thus duplicate event listeners). -// https://github.com/reactive-python/reactpy/pull/1224 + +/** + * Link component that captures clicks on anchor links and notifies the server. + * + * @param {Object} props - The properties object. + * @param {Function} props.onClickCallback - Callback function to notify the server about link clicks. + * @param {string} props.linkClass - The class name of the anchor link. + * @returns {null} This component does not render any visible output. + */ export function Link({ onClickCallback, linkClass }) { + // FIXME: This component is currently unused due to a ReactPy core rendering bug + // which causes duplicate rendering (and thus duplicate event listeners). + // https://github.com/reactive-python/reactpy/pull/1224 + // This component is not the actual anchor link. // It is an event listener for the link component created by ReactPy. React.useEffect(() => { @@ -79,6 +101,15 @@ export function Link({ onClickCallback, linkClass }) { return null; } +/** + * Client-side portion of the navigate component, that allows the server to command the client to change URLs. + * + * @param {Object} props - The properties object. + * @param {Function} props.onNavigateCallback - Callback function that transmits data to the server. + * @param {string} props.to - The target URL to navigate to. + * @param {boolean} props.replace - If true, replaces the current history entry instead of adding a new one. + * @returns {null} This component does not render anything. + */ export function Navigate({ onNavigateCallback, to, replace }) { React.useEffect(() => { if (replace) { From c126be909be7fa8b186ca1bac797f94dc577e0e9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:42:44 -0700 Subject: [PATCH 06/27] Temporary fix for `History`'s first load behavior --- src/js/src/index.js | 22 ++++++++++++++++++++++ src/reactpy_router/components.py | 5 +++++ src/reactpy_router/routers.py | 10 +++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index c59fea3..40e25e0 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -56,6 +56,28 @@ export function History({ onHistoryChangeCallback }) { return null; } +/** + * FirstLoad component that captures the URL during the initial page load and notifies the server. + * + * @param {Object} props - The properties object. + * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load. + * @returns {null} This component does not render any visible output. + * @description + * This component sends the current URL to the server during the initial page load. + * @see https://github.com/reactive-python/reactpy/pull/1224 + */ +export function FirstLoad({ onFirstLoadCallback }) { + // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug + // is fixed. Ideally all this logic would be handled by the `History` component. + React.useEffect(() => { + onFirstLoadCallback({ + pathname: window.location.pathname, + search: window.location.search, + }); + return () => {}; + }, []); + return null; +} /** * Link component that captures clicks on anchor links and notifies the server. diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 39f3654..a417002 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -32,6 +32,11 @@ ) """Client-side portion of the navigate component""" +FirstLoad = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("FirstLoad"), +) + link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 804cf89..83f5f1b 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -11,7 +11,7 @@ from reactpy.backend.types import Connection, Location from reactpy.types import ComponentType, VdomDict -from reactpy_router.components import History +from reactpy_router.components import FirstLoad, History from reactpy_router.hooks import _route_state_context, _RouteState from reactpy_router.resolvers import StarletteResolver from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType @@ -46,6 +46,7 @@ def router( old_conn = use_connection() location, set_location = use_state(old_conn.location) + first_load, set_first_load = use_state(True) resolvers = use_memo( lambda: tuple(map(resolver, _iter_routes(routes))), @@ -69,8 +70,15 @@ def on_history_change(event: dict[str, Any]) -> None: if location != new_location: set_location(new_location) + def on_first_load(event: dict[str, Any]) -> None: + """Callback function used within the JavaScript `FirstLoad` component.""" + if first_load: + set_first_load(False) + on_history_change(event) + return ConnectionContext( History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] + FirstLoad({"onFirstLoadCallback": on_first_load}) if first_load else "", *route_elements, value=Connection(old_conn.scope, location, old_conn.carrier), ) From eee3a873aa307c4782944cd493d5e30d8547197d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:42:53 -0700 Subject: [PATCH 07/27] Longer test delays --- tests/test_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_router.py b/tests/test_router.py index 095a9ee..a8030e0 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 400 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): From b50afd494eb638d7c760e564905f152620c004dc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 22:30:25 -0700 Subject: [PATCH 08/27] Fix `test_router` failures --- src/reactpy_router/routers.py | 2 +- src/reactpy_router/static/link.js | 7 ++++++- tests/test_router.py | 30 ++++++++---------------------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 83f5f1b..cf35e5b 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -74,7 +74,7 @@ def on_first_load(event: dict[str, Any]) -> None: """Callback function used within the JavaScript `FirstLoad` component.""" if first_load: set_first_load(False) - on_history_change(event) + on_history_change(event) return ConnectionContext( History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index b574201..0d588af 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -5,7 +5,12 @@ document.querySelector(".UUID").addEventListener( if (!event.ctrlKey) { event.preventDefault(); let to = event.target.getAttribute("href"); - window.history.pushState({}, to, new URL(to, window.location)); + let new_url = new URL(to, window.location); + + // Deduplication needed due to ReactPy rendering bug + if (new_url.href !== window.location.href) { + window.history.pushState({}, to, new URL(to, window.location)); + } } }, { once: true }, diff --git a/tests/test_router.py b/tests/test_router.py index a8030e0..98dc5c1 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 400 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): @@ -209,32 +209,18 @@ def sample(): await display.show(sample) - for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]: + selectors = ["#root", "#a", "#b", "#c", "#d", "#e", "#f"] + + for link_selector in selectors: _link = await display.page.wait_for_selector(link_selector) await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") - await display.page.go_back() - await display.page.wait_for_selector("#f") - - await display.page.go_back() - await display.page.wait_for_selector("#e") - - await display.page.go_back() - await display.page.wait_for_selector("#d") - - await display.page.go_back() - await display.page.wait_for_selector("#c") - - await display.page.go_back() - await display.page.wait_for_selector("#b") - - await display.page.go_back() - await display.page.wait_for_selector("#a") - - await display.page.go_back() - await display.page.wait_for_selector("#root") + selectors.reverse() + for link_selector in selectors: + await display.page.go_back() + await display.page.wait_for_selector(link_selector) async def test_link_with_query_string(display: DisplayFixture): From d0ac25b693c92f6e4b5ec31feab506a3de0e7c32 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 22:37:42 -0700 Subject: [PATCH 09/27] Fix coverage --- tests/test_router.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_router.py b/tests/test_router.py index 98dc5c1..3443652 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 400 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): @@ -334,3 +334,30 @@ def sample(): await display.page.wait_for_selector("#b") await display.page.go_back() await display.page.wait_for_selector("#nav-a") + + +async def test_navigate_component_to_current_url(display: DisplayFixture): + @component + def navigate_btn(to: str, html_id: str, replace: bool = False): + nav_url, set_nav_url = use_state("") + + return html.button( + {"onClick": lambda _: set_nav_url(to), "id": html_id}, + navigate(nav_url, replace) if nav_url else f"Navigate to {to}", + ) + + @component + def sample(): + return browser_router( + route("/", navigate_btn("/a", "root-a")), + route("/a", navigate_btn("/a", "nav-a")), + ) + + await display.show(sample) + _button = await display.page.wait_for_selector("#root-a") + await _button.click(delay=CLICK_DELAY) + _button = await display.page.wait_for_selector("#nav-a") + await _button.click(delay=CLICK_DELAY) + await display.page.wait_for_selector("#nav-a") + await display.page.go_back() + await display.page.wait_for_selector("#root-a") From 7ff74277802d9fbcfd05cb3cbc2e162c14db65f6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 22:40:46 -0700 Subject: [PATCH 10/27] Increase click delay again --- tests/test_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_router.py b/tests/test_router.py index 3443652..f48287b 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 400 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 500 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): From 9bf48f4ff9394a3879fd263010fe0d70bc38e26b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:17:47 -0700 Subject: [PATCH 11/27] Move first load component to the bottom --- src/js/src/index.js | 46 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index 40e25e0..692a8e3 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -56,29 +56,6 @@ export function History({ onHistoryChangeCallback }) { return null; } -/** - * FirstLoad component that captures the URL during the initial page load and notifies the server. - * - * @param {Object} props - The properties object. - * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load. - * @returns {null} This component does not render any visible output. - * @description - * This component sends the current URL to the server during the initial page load. - * @see https://github.com/reactive-python/reactpy/pull/1224 - */ -export function FirstLoad({ onFirstLoadCallback }) { - // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug - // is fixed. Ideally all this logic would be handled by the `History` component. - React.useEffect(() => { - onFirstLoadCallback({ - pathname: window.location.pathname, - search: window.location.search, - }); - return () => {}; - }, []); - return null; -} - /** * Link component that captures clicks on anchor links and notifies the server. * @@ -148,3 +125,26 @@ export function Navigate({ onNavigateCallback, to, replace }) { return null; } + +/** + * FirstLoad component that captures the URL during the initial page load and notifies the server. + * + * @param {Object} props - The properties object. + * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load. + * @returns {null} This component does not render any visible output. + * @description + * This component sends the current URL to the server during the initial page load. + * @see https://github.com/reactive-python/reactpy/pull/1224 + */ +export function FirstLoad({ onFirstLoadCallback }) { + // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug + // is fixed. Ideally all this logic would be handled by the `History` component. + React.useEffect(() => { + onFirstLoadCallback({ + pathname: window.location.pathname, + search: window.location.search, + }); + return () => {}; + }, []); + return null; +} From 66bef6a6a87a2923f980652f17dc3df4e211dc42 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:21:57 -0700 Subject: [PATCH 12/27] Run coverage on python 3.11 --- .github/workflows/test-src.yaml | 70 ++++++++++++++++----------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index df93152..dbaa759 100644 --- a/.github/workflows/test-src.yaml +++ b/.github/workflows/test-src.yaml @@ -1,40 +1,40 @@ name: Test on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * *" + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" jobs: - source: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11"] - steps: - - uses: actions/checkout@v4 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install -r requirements/test-run.txt - - name: Run Tests - run: nox -t test - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Latest Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install Python Dependencies - run: pip install -r requirements/test-run.txt - - name: Run Tests - run: nox -t test -- --coverage + source: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install -r requirements/test-run.txt + - name: Run Tests + run: nox -t test + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Latest Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python Dependencies + run: pip install -r requirements/test-run.txt + - name: Run Tests + run: nox -t test -- --coverage From 53cdb9e6e181f55941ca0b5e2350ef1b01091968 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:22:06 -0700 Subject: [PATCH 13/27] More clear comment on _match_route --- src/reactpy_router/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index cf35e5b..1bea240 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -122,7 +122,7 @@ def _match_route( # matches via the `Route` component. However, it's kept here to support future changes # or third-party routers. # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as - # a key here, unless we begin throwing errors for duplicate routes. + # a key here. We can potentially fix this by throwing errors for duplicate identical routes. matches.append(_add_route_key(match, resolver.key)) # pragma: no cover if not matches: From 6e64956a63e143a0d7c7cc5a680eb444e8800cfe Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 01:22:58 -0700 Subject: [PATCH 14/27] Update changelog --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f652ffa..c2df3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,14 +42,15 @@ Using the following categories, list your changes in this order: - Rename `CONVERSION_TYPES` to `CONVERTERS`. - Change "Match Any" syntax from a star `*` to `{name:any}`. - Rewrite `reactpy_router.link` to be a server-side component. -- Simplified top-level exports within `reactpy_router`. +- Simplified top-level exports that are available within `reactpy_router.*`. ### Added -- New error for ReactPy router elements being used outside router context. -- Configurable/inheritable `Resolver` base class. - Add debug log message for when there are no router matches. - Add slug as a supported type. +- Add `reactpy_router.navigate` component that will force the client to navigate to a new URL (when rendered). +- New error for ReactPy router elements being used outside router context. +- Configurable/inheritable `Resolver` base class. ### Fixed From c8e06fb1e03e46ad901b1f1d9c778d5b754aebcd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:29:37 -0700 Subject: [PATCH 15/27] Fix tests on windows --- requirements/test-env.txt | 2 +- tests/conftest.py | 16 ++++++---------- tests/test_router.py | 2 ++ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 4ddd635..4b78ca5 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,6 +1,6 @@ twine pytest -pytest-asyncio +anyio pytest-cov reactpy[testing,starlette] nodejs-bin==18.4.0a4 diff --git a/tests/conftest.py b/tests/conftest.py index 18e3646..7d6f0ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,4 @@ -import asyncio import os -import sys import pytest from playwright.async_api import async_playwright @@ -18,27 +16,25 @@ def pytest_addoption(parser) -> None: ) -@pytest.fixture +@pytest.fixture(scope="session") async def display(backend, browser): async with DisplayFixture(backend, browser) as display_fixture: display_fixture.page.set_default_timeout(10000) yield display_fixture -@pytest.fixture +@pytest.fixture(scope="session") async def backend(): async with BackendFixture() as backend_fixture: yield backend_fixture -@pytest.fixture +@pytest.fixture(scope="session") async def browser(pytestconfig): async with async_playwright() as pw: yield await pw.chromium.launch(headless=True if GITHUB_ACTIONS else pytestconfig.getoption("headless")) -@pytest.fixture -def event_loop_policy(request): - if sys.platform == "win32": - return asyncio.WindowsProactorEventLoopPolicy() - return asyncio.get_event_loop_policy() +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" diff --git a/tests/test_router.py b/tests/test_router.py index f48287b..06405d3 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,6 +1,7 @@ import os from typing import Any +import pytest from playwright.async_api._generated import Browser, Page from reactpy import Ref, component, html, use_location, use_state from reactpy.testing import DisplayFixture @@ -9,6 +10,7 @@ GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" CLICK_DELAY = 500 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +pytestmark = pytest.mark.anyio async def test_simple_router(display: DisplayFixture): From fb69cefce2dc01f5c4e53e484e6760f68efe5fcc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:29:49 -0700 Subject: [PATCH 16/27] simplify test_browser_popstate --- tests/test_router.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/tests/test_router.py b/tests/test_router.py index 06405d3..4a212e5 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -176,23 +176,18 @@ def sample(): await display.show(sample) - for link_selector in ["#root", "#a", "#b", "#c"]: + link_selectors = ["#root", "#a", "#b", "#c"] + + for link_selector in link_selectors: _link = await display.page.wait_for_selector(link_selector) await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") - await display.page.go_back() - await display.page.wait_for_selector("#c") - - await display.page.go_back() - await display.page.wait_for_selector("#b") - - await display.page.go_back() - await display.page.wait_for_selector("#a") - - await display.page.go_back() - await display.page.wait_for_selector("#root") + link_selectors.reverse() + for link_selector in link_selectors: + await display.page.go_back() + await display.page.wait_for_selector(link_selector) async def test_relative_links(display: DisplayFixture): From 887564338d4ccb72fb498ce83fbfbb94ac46a3aa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:08:41 -0700 Subject: [PATCH 17/27] Attempt fix for flakey tests: add sleep before each `go_back` action --- tests/test_router.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_router.py b/tests/test_router.py index 4a212e5..a6a27a8 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,3 +1,4 @@ +import asyncio import os from typing import Any @@ -186,6 +187,7 @@ def sample(): link_selectors.reverse() for link_selector in link_selectors: + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() await display.page.wait_for_selector(link_selector) @@ -216,6 +218,7 @@ def sample(): selectors.reverse() for link_selector in selectors: + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() await display.page.wait_for_selector(link_selector) @@ -301,6 +304,7 @@ def sample(): _button = await display.page.wait_for_selector("button") await _button.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#a") + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() await display.page.wait_for_selector("button") @@ -329,6 +333,7 @@ def sample(): _button = await display.page.wait_for_selector("#nav-b") await _button.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#b") + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() await display.page.wait_for_selector("#nav-a") @@ -356,5 +361,6 @@ def sample(): _button = await display.page.wait_for_selector("#nav-a") await _button.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#nav-a") + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() await display.page.wait_for_selector("#root-a") From 698eed904c14573f4ed0159a402fd3401fdb4978 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:15:14 -0700 Subject: [PATCH 18/27] Attempt fix for flakey tests: Check if new page already exists before waiting for page event. --- tests/test_router.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_router.py b/tests/test_router.py index a6a27a8..2c5ec10 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -10,7 +10,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 500 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. pytestmark = pytest.mark.anyio @@ -279,7 +279,10 @@ def sample(): _link = await display.page.wait_for_selector("#root") await _link.click(delay=CLICK_DELAY, modifiers=["Control"]) browser_context = browser.contexts[0] - new_page: Page = await browser_context.wait_for_event("page") + if len(browser_context.pages) == 1: + new_page: Page = await browser_context.wait_for_event("page") + else: + new_page: Page = browser_context.pages[-1] # type: ignore[no-redef] await new_page.wait_for_selector("#a") From e1c442a85f0a68377005d6ed92451e56b0703c8e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:19:12 -0700 Subject: [PATCH 19/27] more comments --- src/js/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index 692a8e3..4e7f02f 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -43,7 +43,7 @@ export function History({ onHistoryChangeCallback }) { // Tell the server about the URL during the initial page load // FIXME: This code is commented out since it currently runs every time any component - // is mounted due to a ReactPy core rendering bug. + // is mounted due to a ReactPy core rendering bug. `FirstLoad` component is used instead. // https://github.com/reactive-python/reactpy/pull/1224 // React.useEffect(() => { From 7c169b0bf082b3f34b336f9fc3a1c8be7aaed159 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:19:21 -0700 Subject: [PATCH 20/27] Add python 3.12 tests --- .github/workflows/test-src.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index dbaa759..687741b 100644 --- a/.github/workflows/test-src.yaml +++ b/.github/workflows/test-src.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} @@ -33,7 +33,7 @@ jobs: - name: Use Latest Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.x" - name: Install Python Dependencies run: pip install -r requirements/test-run.txt - name: Run Tests From a6c378f040cd7b222cdd9a95c0d61b44914fb71f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:19:33 -0700 Subject: [PATCH 21/27] Attempt decreasing GH delay to 250ms --- tests/test_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_router.py b/tests/test_router.py index 2c5ec10..054c51c 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -10,7 +10,7 @@ from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. pytestmark = pytest.mark.anyio From a8a7d2748e0cf24c419cc08328e1cc040339887c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:27:45 -0700 Subject: [PATCH 22/27] Standardize history.pushstate args --- src/reactpy_router/static/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index 0d588af..9f78cc5 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -9,7 +9,7 @@ document.querySelector(".UUID").addEventListener( // Deduplication needed due to ReactPy rendering bug if (new_url.href !== window.location.href) { - window.history.pushState({}, to, new URL(to, window.location)); + window.history.pushState(null, "", new URL(to, window.location)); } } }, From 364e186419a46132c7aa9e5283e54543e3b88a88 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:27:52 -0700 Subject: [PATCH 23/27] Remove unused pytest arg --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d6a0110..09826fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,4 +17,3 @@ line-length = 120 [tool.pytest.ini_options] testpaths = "tests" -asyncio_mode = "auto" From 0084b569cc5cf82a7cd75368504bbd753a845370 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Oct 2024 01:16:09 -0700 Subject: [PATCH 24/27] minor test tweaks --- tests/test_router.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_router.py b/tests/test_router.py index 054c51c..1a4d95c 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -343,12 +343,12 @@ def sample(): async def test_navigate_component_to_current_url(display: DisplayFixture): @component - def navigate_btn(to: str, html_id: str, replace: bool = False): + def navigate_btn(to: str, html_id: str): nav_url, set_nav_url = use_state("") return html.button( {"onClick": lambda _: set_nav_url(to), "id": html_id}, - navigate(nav_url, replace) if nav_url else f"Navigate to {to}", + navigate(nav_url) if nav_url else f"Navigate to {to}", ) @component @@ -363,6 +363,7 @@ def sample(): await _button.click(delay=CLICK_DELAY) _button = await display.page.wait_for_selector("#nav-a") await _button.click(delay=CLICK_DELAY) + await asyncio.sleep(CLICK_DELAY / 1000) await display.page.wait_for_selector("#nav-a") await asyncio.sleep(CLICK_DELAY / 1000) await display.page.go_back() From 1408d0912e2fbbf63fb438e6d7e1980beecdfdac Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Oct 2024 01:16:27 -0700 Subject: [PATCH 25/27] add changelog for fixing win tests --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2df3df..bf4e08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Using the following categories, list your changes in this order: - Fix bug where `link` elements could not have `@component` type children. - Fix bug where the ReactPy would not detect the current URL after a reconnection. - Fix bug where `ctrl` + `click` on a `link` element would not open in a new tab. +- Fix test suite on Windows machines. ## [0.1.1] - 2023-12-13 From 109d87b5a2fcc19e49a33203a056e2fe3c4db1ce Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Oct 2024 00:12:03 -0700 Subject: [PATCH 26/27] more robust docs --- docs/mkdocs.yml | 17 +++- docs/src/dictionary.txt | 1 + docs/src/reference/{router.md => routers.md} | 0 docs/src/reference/types.md | 4 + requirements/build-docs.txt | 1 + src/reactpy_router/components.py | 39 ++++++-- src/reactpy_router/hooks.py | 33 +++---- src/reactpy_router/routers.py | 25 ++++-- src/reactpy_router/types.py | 95 ++++++++++++++++---- 9 files changed, 169 insertions(+), 46 deletions(-) rename docs/src/reference/{router.md => routers.md} (100%) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 5173834..ebf8b0e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -8,7 +8,7 @@ nav: - Hooks: learn/hooks.md - Creating a Custom Router 🚧: learn/custom-router.md - Reference: - - Router Components: reference/router.md + - Routers: reference/routers.md - Components: reference/components.md - Hooks: reference/hooks.md - Types: reference/types.md @@ -96,8 +96,21 @@ plugins: - https://reactpy.dev/docs/objects.inv - https://installer.readthedocs.io/en/stable/objects.inv options: - show_bases: false + signature_crossrefs: true + scoped_crossrefs: true + relative_crossrefs: true + modernize_annotations: true + unwrap_annotated: true + find_stubs_package: true show_root_members_full_path: true + show_bases: false + show_source: false + show_root_toc_entry: false + show_labels: false + show_symbol_type_toc: true + show_symbol_type_heading: true + show_object_full_path: true + heading_level: 3 extra: generator: false version: diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 6eb9552..64ed74d 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -37,3 +37,4 @@ misconfiguration misconfigurations backhaul sublicense +contravariant diff --git a/docs/src/reference/router.md b/docs/src/reference/routers.md similarity index 100% rename from docs/src/reference/router.md rename to docs/src/reference/routers.md diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md index 204bee7..3898ae8 100644 --- a/docs/src/reference/types.md +++ b/docs/src/reference/types.md @@ -1 +1,5 @@ ::: reactpy_router.types + + options: + summary: true + docstring_section_style: "list" diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index f6561b3..57805cb 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -9,4 +9,5 @@ mkdocs-minify-plugin mkdocs-section-index mike mkdocstrings[python] +black # for mkdocstrings automatic code formatting . diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index a417002..657c558 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -41,13 +41,21 @@ def link(attributes: dict[str, Any], *children: Any) -> Component: - """Create a link with the given attributes and children.""" + """ + Create a link with the given attributes and children. + + Args: + attributes: A dictionary of attributes for the link. + *children: Child elements to be included within the link. + + Returns: + A link component with the specified attributes and children. + """ return _link(attributes, *children) @component def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: - """A component that renders a link to the given path.""" attributes = attributes.copy() uuid_string = f"link-{uuid4().hex}" class_name = f"{uuid_string}" @@ -110,18 +118,39 @@ def on_click(_event: dict[str, Any]) -> None: def route(path: str, element: Any | None, *routes: Route) -> Route: - """Create a route with the given path, element, and child routes.""" + """ + Create a route with the given path, element, and child routes. + + Args: + path: The path for the route. + element: The element to render for this route. Can be None. + routes: Additional child routes. + + Returns: + The created route object. + """ return Route(path, element, routes) def navigate(to: str, replace: bool = False) -> Component: - """A `navigate` element changes the current location when it is rendered.""" + """ + 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. + replace: If True, the current history entry will be replaced \ + with the new URL. Defaults to False. + + Returns: + The component responsible for navigation. + """ return _navigate(to, replace) @component def _navigate(to: str, replace: bool = False) -> VdomDict | None: - """A `navigate` element changes the current location when it is rendered.""" location = use_connection().location set_location = _use_route_state().set_location pathname = to.split("?", 1)[0] diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 3831acf..add8953 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -1,21 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from urllib.parse import parse_qs from reactpy import create_context, use_context, use_location -from reactpy.backend.types import Location from reactpy.types import Context +from reactpy_router.types import RouteState -@dataclass -class _RouteState: - set_location: Callable[[Location], None] - params: dict[str, Any] +_route_state_context: Context[RouteState | None] = create_context(None) -def _use_route_state() -> _RouteState: +def _use_route_state() -> RouteState: route_state = use_context(_route_state_context) if route_state is None: # pragma: no cover raise RuntimeError( @@ -26,16 +22,17 @@ def _use_route_state() -> _RouteState: return route_state -_route_state_context: Context[_RouteState | None] = create_context(None) - - def use_params() -> dict[str, Any]: - """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \ + """This hook returns an object of key/value pairs of the dynamic parameters \ from the current URL that were matched by the `Route`. Child routes inherit all parameters \ from their parent routes. For example, if you have a `URL_PARAM` defined in the route `/example//`, - this hook will return the URL_PARAM value that was matched.""" + this hook will return the `URL_PARAM` value that was matched. + + Returns: + A dictionary of the current URL's parameters. + """ # TODO: Check if this returns all parent params return _use_route_state().params @@ -49,10 +46,14 @@ def use_search_params( separator: str = "&", ) -> dict[str, list[str]]: """ - The `use_search_params` hook is used to read the query string in the URL \ - for the current location. + This hook is used to read the query string in the URL for the current location. + + See [`urllib.parse.parse_qs`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.parse_qs) \ + for info on this hook's parameters. - See `urllib.parse.parse_qs` for info on this hook's parameters.""" + Returns: + A dictionary of the current URL's query string parameters. + """ location = use_location() query_string = location.search[1:] if len(location.search) > 1 else "" diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 1bea240..a815f0d 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -9,10 +9,11 @@ from reactpy import component, use_memo, use_state from reactpy.backend.hooks import ConnectionContext, use_connection from reactpy.backend.types import Connection, Location +from reactpy.core.component import Component from reactpy.types import ComponentType, VdomDict from reactpy_router.components import FirstLoad, History -from reactpy_router.hooks import _route_state_context, _RouteState +from reactpy_router.hooks import RouteState, _route_state_context from reactpy_router.resolvers import StarletteResolver from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType @@ -23,15 +24,27 @@ def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]: """A decorator that turns a resolver into a router""" - def wrapper(*routes: RouteType) -> ComponentType: + def wrapper(*routes: RouteType) -> Component: return router(*routes, resolver=resolver) return wrapper -browser_router = create_router(StarletteResolver) -"""This is the recommended router for all ReactPy Router web projects. -It uses the JavaScript DOM History API to manage the history stack.""" +_starlette_router = create_router(StarletteResolver) + + +def browser_router(*routes: RouteType) -> Component: + """This is the recommended router for all ReactPy-Router web projects. + It uses the JavaScript [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) + to manage the history stack. + + Args: + *routes (RouteType): A list of routes to be rendered by the router. + + Returns: + A router component that renders the given routes. + """ + return _starlette_router(*routes) @component @@ -59,7 +72,7 @@ def router( route_elements = [ _route_state_context( element, - value=_RouteState(set_location, params), + value=RouteState(set_location, params), ) for element, params in match ] diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 15a77c4..87f7d7f 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -5,26 +5,36 @@ from dataclasses import dataclass, field from typing import Any, Callable, Sequence, TypedDict, TypeVar +from reactpy.backend.types import Location +from reactpy.core.component import Component from reactpy.core.vdom import is_vdom -from reactpy.types import ComponentType, Key +from reactpy.types import Key from typing_extensions import Protocol, Self, TypeAlias ConversionFunc: TypeAlias = Callable[[str], Any] +"""A function that converts a string to a specific type.""" + ConverterMapping: TypeAlias = dict[str, ConversionFunc] +"""A mapping of conversion types to their respective functions.""" @dataclass(frozen=True) class Route: - """A route that can be matched against a path.""" + """ + A class representing a route that can be matched against a path. - path: str - """The path to match against.""" + Attributes: + path (str): The path to match against. + element (Any): The element to render if the path matches. + routes (Sequence[Self]): Child routes. - element: Any = field(hash=False) - """The element to render if the path matches.""" + Methods: + __hash__() -> int: Returns a hash value for the route based on its path, element, and child routes. + """ + path: str + element: Any = field(hash=False) routes: Sequence[Self] - """Child routes.""" def __hash__(self) -> int: el = self.element @@ -33,36 +43,87 @@ def __hash__(self) -> int: RouteType = TypeVar("RouteType", bound=Route) +"""A type variable for `Route`.""" + RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True) +"""A contravariant type variable for `Route`.""" class Router(Protocol[RouteType_contra]): - """Return a component that renders the first matching route.""" + """Return a component that renders the matching route(s).""" + + def __call__(self, *routes: RouteType_contra) -> Component: + """ + Process the given routes and return a component that renders the matching route(s). - def __call__(self, *routes: RouteType_contra) -> ComponentType: ... + Args: + *routes: A variable number of route arguments. + + Returns: + The resulting component after processing the routes. + """ class Resolver(Protocol[RouteType_contra]): """Compile a route into a resolver that can be matched against a given path.""" - def __call__(self, route: RouteType_contra) -> CompiledRoute: ... + def __call__(self, route: RouteType_contra) -> CompiledRoute: + """ + Compile a route into a resolver that can be matched against a given path. + + Args: + route: The route to compile. + + Returns: + The compiled route. + """ class CompiledRoute(Protocol): - """A compiled route that can be matched against a path.""" + """ + A protocol for a compiled route that can be matched against a path. + + Attributes: + key (Key): A property that uniquely identifies this resolver. + """ @property - def key(self) -> Key: - """Uniquely identified this resolver.""" + def key(self) -> Key: ... def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: - """Return the path's associated element and path parameters or None.""" + """ + Return the path's associated element and path parameters or None. + + Args: + path (str): The path to resolve. + + Returns: + A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved. + """ class ConversionInfo(TypedDict): - """Information about a conversion type.""" + """ + A TypedDict that holds information about a conversion type. + + Attributes: + regex (str): The regex to match the conversion type. + func (ConversionFunc): The function to convert the matched string to the expected type. + """ regex: str - """The regex to match the conversion type.""" func: ConversionFunc - """The function to convert the matched string to the expected type.""" + + +@dataclass +class RouteState: + """ + Represents the state of a route in the application. + + Attributes: + set_location: A callable to set the location. + params: A dictionary containing route parameters. + """ + + set_location: Callable[[Location], None] + params: dict[str, Any] From 0e4304c2b6dc664d0531898a3820326e2ce16c12 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Oct 2024 00:15:04 -0700 Subject: [PATCH 27/27] Format all docs examples --- docs/examples/python/basic-routing-more-routes.py | 1 + docs/examples/python/basic-routing.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py index 32bb31e..14c9b5a 100644 --- a/docs/examples/python/basic-routing-more-routes.py +++ b/docs/examples/python/basic-routing-more-routes.py @@ -1,4 +1,5 @@ from reactpy import component, html, run + from reactpy_router import browser_router, route diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py index 43c4e65..efc7835 100644 --- a/docs/examples/python/basic-routing.py +++ b/docs/examples/python/basic-routing.py @@ -1,4 +1,5 @@ from reactpy import component, html, run + from reactpy_router import browser_router, route