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):