From 2d79831a5b0ad333b6a66b05bfb62cb098c3f237 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 11 Jan 2025 22:40:16 -0800 Subject: [PATCH] Refactoring related to ReactPy v1.1.0 (#50) --- .github/workflows/test-python.yml | 15 ++++ CHANGELOG.md | 26 ++---- README.md | 4 +- docs/src/about/contributing.md | 1 + pyproject.toml | 14 +++- src/js/src/components.ts | 101 +++++++++++++++++++++++ src/js/src/index.ts | 130 +----------------------------- src/js/src/types.ts | 4 - src/js/src/utils.ts | 12 ++- src/reactpy_router/components.py | 53 ++---------- src/reactpy_router/routers.py | 31 +++---- src/reactpy_router/static/link.js | 17 ---- src/reactpy_router/types.py | 6 +- 13 files changed, 169 insertions(+), 245 deletions(-) create mode 100644 src/js/src/components.ts delete mode 100644 src/reactpy_router/static/link.js diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 9390316..bbac572 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -80,3 +80,18 @@ jobs: run: pip install --upgrade pip hatch uv - name: Check Python formatting run: hatch fmt src tests --check + + python-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Run Python type checker + run: hatch run python:type_check diff --git a/CHANGELOG.md b/CHANGELOG.md index 6658cea..1fced87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,25 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +Don't forget to remove deprecated code on each major release! +--> @@ -36,7 +21,10 @@ Using the following categories, list your changes in this order: ### Changed -- Set upper limit on ReactPy version to `<2.0.0`. +- Set maximum ReactPy version to `<2.0.0`. +- Set minimum ReactPy version to `1.1.0`. +- `link` element now calculates URL changes using the client. +- Refactoring related to `reactpy>=1.1.0` changes. ### Fixed diff --git a/README.md b/README.md index 4fcafc9..24ad465 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ReactPy Router

- - + + diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index c7cf012..82b82f1 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -43,6 +43,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch fmt --formatter` | Run only formatters | | `hatch run javascript:check` | Run the JavaScript linter/formatter | | `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | +| `hatch run python:type_check` | Run the Python type checker | ??? tip "Configure your IDE for linting" diff --git a/pyproject.toml b/pyproject.toml index 6472bdf..0f3d7f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Environment :: Web Environment", "Typing :: Typed", ] -dependencies = ["reactpy>=1.0.0, <2.0.0", "typing_extensions"] +dependencies = ["reactpy>=1.1.0, <2.0.0", "typing_extensions"] dynamic = ["version"] urls.Changelog = "https://reactive-python.github.io/reactpy-router/latest/about/changelog/" urls.Documentation = "https://reactive-python.github.io/reactpy-router/latest/" @@ -53,7 +53,7 @@ installer = "uv" [[tool.hatch.build.hooks.build-scripts.scripts]] commands = [ "bun install --cwd src/js", - "bun build src/js/src/index.js --outfile src/reactpy_router/static/bundle.js --minify", + "bun build src/js/src/index.ts --outfile src/reactpy_router/static/bundle.js --minify", ] artifacts = [] @@ -106,6 +106,16 @@ linkcheck = [ deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] +################################ +# >>> Hatch Python Scripts <<< # +################################ + +[tool.hatch.envs.python] +extra-dependencies = ["pyright"] + +[tool.hatch.envs.python.scripts] +type_check = ["pyright src"] + ############################ # >>> Hatch JS Scripts <<< # ############################ diff --git a/src/js/src/components.ts b/src/js/src/components.ts new file mode 100644 index 0000000..4712637 --- /dev/null +++ b/src/js/src/components.ts @@ -0,0 +1,101 @@ +import React from "preact/compat"; +import ReactDOM from "preact/compat"; +import { createLocationObject, pushState, replaceState } from "./utils"; +import { HistoryProps, LinkProps, NavigateProps } from "./types"; + +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +/** + * History component that captures browser "history go back" actions and notifies the server. + */ +export function History({ onHistoryChangeCallback }: HistoryProps): null { + // Tell the server about history "popstate" events + React.useEffect(() => { + const listener = () => { + onHistoryChangeCallback(createLocationObject()); + }; + + // Register the event listener + window.addEventListener("popstate", listener); + + // Delete the event listener when the component is unmounted + return () => window.removeEventListener("popstate", listener); + }); + + // Tell the server about the URL during the initial page load + React.useEffect(() => { + onHistoryChangeCallback(createLocationObject()); + return () => {}; + }, []); + return null; +} + +/** + * Link component that captures clicks on anchor links and notifies the server. + * + * This component is not the actual `` link element. It is just an event + * listener for ReactPy-Router's server-side link component. + */ +export function Link({ onClickCallback, linkClass }: LinkProps): null { + React.useEffect(() => { + // Event function that will tell the server about clicks + const handleClick = (event: Event) => { + let click_event = event as MouseEvent; + if (!click_event.ctrlKey) { + event.preventDefault(); + let to = (event.currentTarget as HTMLElement).getAttribute("href"); + pushState(to); + onClickCallback(createLocationObject()); + } + }; + + // Register the event listener + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.addEventListener("click", handleClick); + } else { + console.warn(`Link component with class name ${linkClass} not found.`); + } + + // Delete the event listener when the component is unmounted + return () => { + if (link) { + link.removeEventListener("click", handleClick); + } + }; + }); + return null; +} + +/** + * Client-side portion of the navigate component, that allows the server to command the client to change URLs. + */ +export function Navigate({ + onNavigateCallback, + to, + replace = false, +}: NavigateProps): null { + React.useEffect(() => { + if (replace) { + replaceState(to); + } else { + pushState(to); + } + onNavigateCallback(createLocationObject()); + return () => {}; + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index d7c6b3e..8de0626 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,129 +1 @@ -import React from "preact/compat"; -import ReactDOM from "preact/compat"; -import { createLocationObject, pushState, replaceState } from "./utils"; -import { - HistoryProps, - LinkProps, - NavigateProps, - FirstLoadProps, -} from "./types"; - -/** - * Interface used to bind a ReactPy node to React. - */ -export function bind(node) { - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => { - ReactDOM.render(element, node); - }, - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; -} - -/** - * History component that captures browser "history go back" actions and notifies the server. - */ -export function History({ onHistoryChangeCallback }: HistoryProps): null { - React.useEffect(() => { - // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. - const listener = () => { - onHistoryChangeCallback(createLocationObject()); - }; - - // Register the event listener - window.addEventListener("popstate", listener); - - // Delete the event listener when the component is unmounted - return () => window.removeEventListener("popstate", listener); - }); - - // 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. `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 () => {}; - // }, []); - return null; -} - -/** - * Link component that captures clicks on anchor links and notifies the server. - * - * This component is not the actual `` link element. It is just an event - * listener for ReactPy-Router's server-side link component. - * - * @disabled This component is currently unused due to a ReactPy core rendering bug - * which causes duplicate rendering (and thus duplicate event listeners). - */ -export function Link({ onClickCallback, linkClass }: LinkProps): null { - React.useEffect(() => { - // Event function that will tell the server about clicks - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - let to = (event.target as HTMLElement).getAttribute("href"); - pushState(to); - onClickCallback(createLocationObject()); - }; - - // Register the event listener - let link = document.querySelector(`.${linkClass}`); - if (link) { - link.addEventListener("click", handleClick); - } else { - console.warn(`Link component with class name ${linkClass} not found.`); - } - - // Delete the event listener when the component is unmounted - return () => { - let link = document.querySelector(`.${linkClass}`); - if (link) { - link.removeEventListener("click", handleClick); - } - }; - }); - return null; -} - -/** - * Client-side portion of the navigate component, that allows the server to command the client to change URLs. - */ -export function Navigate({ - onNavigateCallback, - to, - replace = false, -}: NavigateProps): null { - React.useEffect(() => { - if (replace) { - replaceState(to); - } else { - pushState(to); - } - onNavigateCallback(createLocationObject()); - return () => {}; - }, []); - - return null; -} - -/** - * FirstLoad component that captures the URL during the initial page load and notifies the server. - * - * FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug - * is fixed. In the future, all this logic should be handled by the `History` component. - * https://github.com/reactive-python/reactpy/pull/1224 - */ -export function FirstLoad({ onFirstLoadCallback }: FirstLoadProps): null { - React.useEffect(() => { - onFirstLoadCallback(createLocationObject()); - return () => {}; - }, []); - - return null; -} +export { bind, History, Link, Navigate } from "./components"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index f4cf6cd..7144668 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -17,7 +17,3 @@ export interface NavigateProps { to: string; replace?: boolean; } - -export interface FirstLoadProps { - onFirstLoadCallback: (location: ReactPyLocation) => void; -} diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index a0d1af7..e3f1dd5 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -7,10 +7,18 @@ export function createLocationObject(): ReactPyLocation { }; } -export function pushState(to: string): void { +export function pushState(to: any): void { + if (typeof to !== "string") { + console.error("pushState() requires a string argument."); + return; + } window.history.pushState(null, "", new URL(to, window.location.href)); } -export function replaceState(to: string): void { +export function replaceState(to: any): void { + if (typeof to !== "string") { + console.error("replaceState() requires a string argument."); + return; + } window.history.replaceState(null, "", new URL(to, window.location.href)); } diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 6a84799..6a751e7 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -2,10 +2,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin from uuid import uuid4 -from reactpy import component, html, use_connection +from reactpy import component, html, use_connection, use_ref from reactpy.backend.types import Location from reactpy.web.module import export, module_from_file @@ -34,13 +33,6 @@ ) """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, key: Key | None = None) -> Component: """ @@ -59,8 +51,7 @@ def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> @component def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: attributes = attributes.copy() - uuid_string = f"link-{uuid4().hex}" - class_name = f"{uuid_string}" + class_name = use_ref(f"link-{uuid4().hex}").current set_location = _use_route_state().set_location if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) @@ -80,44 +71,10 @@ def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: "className": class_name, } - # FIXME: This component currently works in a "dumb" way by trusting that ReactPy's script tag \ - # properly sets the location due to bugs in ReactPy rendering. - # https://github.com/reactive-python/reactpy/pull/1224 - current_path = use_connection().location.pathname - - def on_click(_event: dict[str, Any]) -> None: - if _event.get("ctrlKey", False): - return - - pathname, search = to.split("?", 1) if "?" in to else (to, "") - if search: - search = f"?{search}" - - # Resolve relative paths that match `../foo` - if pathname.startswith("../"): - pathname = urljoin(current_path, pathname) - - # Resolve relative paths that match `foo` - if not pathname.startswith("/"): - pathname = urljoin(current_path, pathname) - - # Resolve relative paths that match `/foo/../bar` - while "/../" in pathname: - part_1, part_2 = pathname.split("/../", 1) - pathname = urljoin(f"{part_1}/", f"../{part_2}") - - # Resolve relative paths that match `foo/./bar` - pathname = pathname.replace("/./", "/") - - set_location(Location(pathname, search)) - - attrs["onClick"] = on_click - - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) + def on_click_callback(_event: dict[str, Any]) -> None: + set_location(Location(**_event)) - # def on_click_callback(_event: dict[str, Any]) -> None: - # set_location(Location(**_event)) - # return html._(html.a(attrs, *children), Link({"onClickCallback": on_click_callback, "linkClass": uuid_string})) + return html._(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children)) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index d8e75f2..25c37b4 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Any, Literal, 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.hooks import ConnectionContext, use_connection from reactpy.types import ComponentType, VdomDict -from reactpy_router.components import FirstLoad, History +from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context from reactpy_router.resolvers import StarletteResolver @@ -20,16 +20,16 @@ from reactpy.core.component import Component - from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType + from reactpy_router.types import CompiledRoute, Resolver, Route, Router __all__ = ["browser_router", "create_router"] _logger = getLogger(__name__) -def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]: +def create_router(resolver: Resolver[Route]) -> Router[Route]: """A decorator that turns a resolver into a router""" - def wrapper(*routes: RouteType) -> Component: + def wrapper(*routes: Route) -> Component: return router(*routes, resolver=resolver) return wrapper @@ -38,13 +38,13 @@ def wrapper(*routes: RouteType) -> Component: _starlette_router = create_router(StarletteResolver) -def browser_router(*routes: RouteType) -> Component: +def browser_router(*routes: Route) -> 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. + *routes (Route): A list of routes to be rendered by the router. Returns: A router component that renders the given routes. @@ -54,8 +54,8 @@ def browser_router(*routes: RouteType) -> Component: @component def router( - *routes: RouteType, - resolver: Resolver[RouteType], + *routes: Route, + resolver: Resolver[Route], ) -> VdomDict | None: """A component that renders matching route(s) using the given resolver. @@ -76,9 +76,9 @@ def router( if match: if first_load: # We need skip rendering the application on 'first_load' to avoid - # rendering it twice. The second render occurs following - # the impending on_history_change event + # rendering it twice. The second render follows the on_history_change event route_elements = [] + set_first_load(False) else: route_elements = [ _route_state_context( @@ -94,15 +94,8 @@ 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), ) @@ -110,7 +103,7 @@ def on_first_load(event: dict[str, Any]) -> None: return None -def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]: +def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]: for parent in routes: for child in _iter_routes(parent.routes): yield replace(child, path=parent.path + child.path) # type: ignore[misc] diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js deleted file mode 100644 index 7ab069b..0000000 --- a/src/reactpy_router/static/link.js +++ /dev/null @@ -1,17 +0,0 @@ -document.querySelector(".UUID").addEventListener( - "click", - (event) => { - // Prevent default if ctrl isn't pressed - if (!event.ctrlKey) { - event.preventDefault(); - let to = event.currentTarget.getAttribute("href"); - 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 81404b7..ca2c913 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -46,9 +46,6 @@ def __hash__(self) -> int: return hash((self.path, key, self.routes)) -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`.""" @@ -66,6 +63,7 @@ def __call__(self, *routes: RouteType_contra) -> Component: Returns: The resulting component after processing the routes. """ + ... class Resolver(Protocol[RouteType_contra]): @@ -81,6 +79,7 @@ def __call__(self, route: RouteType_contra) -> CompiledRoute: Returns: The compiled route. """ + ... class CompiledRoute(Protocol): @@ -104,6 +103,7 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: Returns: A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved. """ + ... class ConversionInfo(TypedDict):