From 2f9be017c40e4b8a84c41b49ae221c2d8fc2b15e Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 17 Oct 2024 00:36:16 -0700 Subject: [PATCH] New `Navigate` component (#34) - Create `navigate` component - Better key value identity of route components - Remove initial URL handling from the `History` component due to reactpy rendering bugs - This is now handled by a new `FirstLoad` element. - Fix docs publishing workflow - Add arg descriptions to all public functions - Better styling for autodocs. - Support Python 3.12 --- .github/workflows/test-docs.yml | 1 - .github/workflows/test-src.yaml | 70 ++++----- CHANGELOG.md | 8 +- .../python/basic-routing-more-routes.py | 1 + docs/examples/python/basic-routing.py | 1 + docs/mkdocs.yml | 17 +- docs/src/dictionary.txt | 1 + docs/src/reference/components.md | 2 +- docs/src/reference/{router.md => routers.md} | 0 docs/src/reference/types.md | 4 + pyproject.toml | 1 - requirements/build-docs.txt | 2 + requirements/test-env.txt | 2 +- src/js/src/index.js | 106 ++++++++++--- src/reactpy_router/__init__.py | 3 +- src/reactpy_router/components.py | 71 ++++++++- src/reactpy_router/hooks.py | 33 ++-- src/reactpy_router/routers.py | 60 +++++-- src/reactpy_router/static/link.js | 7 +- src/reactpy_router/types.py | 95 +++++++++-- tests/conftest.py | 16 +- tests/test_router.py | 147 +++++++++++++----- 22 files changed, 486 insertions(+), 162 deletions(-) rename docs/src/reference/{router.md => routers.md} (100%) 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/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index df93152..687741b 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", "3.12"] + 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.x" + - name: Install Python Dependencies + run: pip install -r requirements/test-run.txt + - name: Run Tests + run: nox -t test -- --coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index f652ffa..bf4e08c 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 @@ -58,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 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 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/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/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/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" diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index 0d2bca2..57805cb 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -9,3 +9,5 @@ mkdocs-minify-plugin mkdocs-section-index mike mkdocstrings[python] +black # for mkdocstrings automatic code formatting +. 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/src/js/src/index.js b/src/js/src/index.js index 8ead7eb..4e7f02f 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,13 +12,23 @@ export function bind(node) { }; } -export function History({ onHistoryChange }) { - // Capture browser "history go back" action and tell the server about it - // Note: Browsers do not allow us to detect "history go forward" actions. +/** + * 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 }) { 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, }); @@ -32,22 +42,33 @@ 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. + // FIXME: This code is commented out since it currently runs every time any component + // is mounted due to a ReactPy core rendering bug. `FirstLoad` component is used instead. // 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 }) { +/** + * 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(() => { @@ -55,8 +76,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 +99,52 @@ export function Link({ onClick, 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) { + 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; +} + +/** + * 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; +} 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..657c558 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -26,17 +26,36 @@ ) """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""" + +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") 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}" @@ -93,11 +112,53 @@ 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.""" + """ + 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: + """ + 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: + 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/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 25b72c1..a815f0d 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -4,16 +4,16 @@ 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.core.component import Component +from reactpy.types import ComponentType, VdomDict -from reactpy_router.components import History -from reactpy_router.hooks import _route_state_context, _RouteState +from reactpy_router.components import FirstLoad, History +from reactpy_router.hooks import RouteState, _route_state_context from reactpy_router.resolvers import StarletteResolver from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType @@ -24,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 @@ -47,6 +59,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))), @@ -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 ] @@ -70,8 +83,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({"onHistoryChange": on_history_change}), # type: ignore[return-value] + 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), ) @@ -86,6 +106,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 +129,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. 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: _logger.debug("No matching route found for %s", location.pathname) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index b574201..9f78cc5 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(null, "", new URL(to, window.location)); + } } }, { once: true }, 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] 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 d6e0deb..1a4d95c 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,14 +1,17 @@ +import asyncio import os from typing import Any +import pytest 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. +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +pytestmark = pytest.mark.anyio async def test_simple_router(display: DisplayFixture): @@ -174,23 +177,19 @@ 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 asyncio.sleep(CLICK_DELAY / 1000) + await display.page.go_back() + await display.page.wait_for_selector(link_selector) async def test_relative_links(display: DisplayFixture): @@ -209,32 +208,19 @@ 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 asyncio.sleep(CLICK_DELAY / 1000) + await display.page.go_back() + await display.page.wait_for_selector(link_selector) async def test_link_with_query_string(display: DisplayFixture): @@ -293,5 +279,92 @@ 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") + + +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 asyncio.sleep(CLICK_DELAY / 1000) + 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 asyncio.sleep(CLICK_DELAY / 1000) + 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): + nav_url, set_nav_url = use_state("") + + return html.button( + {"onClick": lambda _: set_nav_url(to), "id": html_id}, + navigate(nav_url) 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 asyncio.sleep(CLICK_DELAY / 1000) + 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")