Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ Don't forget to remove deprecated code on each major release!

## [Unreleased]

- Nothing (yet)!
### Changed

- Bump required ReactPy version to `2.x`

## [2.0.0] - 2025-06-14

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/python/use_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
@component
def user():
params = use_params()
return html._(html.h1(f"User {params['id']} 👤"), html.p("Nothing (yet)."))
return html(html.h1(f"User {params['id']} 👤"), html.p("Nothing (yet)."))


@component
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/python/use_search_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
@component
def search():
search_params = use_search_params()
return html._(html.h1(f"Search Results for {search_params['query'][0]} 🔍"), html.p("Nothing (yet)."))
return html(html.h1(f"Search Results for {search_params['query'][0]} 🔍"), html.p("Nothing (yet)."))


@component
Expand Down
2 changes: 1 addition & 1 deletion docs/src/learn/routers-routes-and-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The [`browser_router`][reactpy_router.browser_router] component is one possible
!!! abstract "Note"

The current location is determined based on the browser's current URL and can be found
by checking the [`use_location`][reactpy.backend.hooks.use_location] hook.
by checking the [`use_location`][reactpy.use_location] hook.

Here's a basic example showing how to use `#!python browser_router` with two routes.

Expand Down
40 changes: 29 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ readme = "README.md"
keywords = ["React", "ReactJS", "ReactPy", "components"]
license = "MIT"
authors = [{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" }]
requires-python = ">=3.9"
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
Expand All @@ -28,7 +28,11 @@ classifiers = [
"Environment :: Web Environment",
"Typing :: Typed",
]
dependencies = ["reactpy>=1.1.0, <2.0.0", "typing_extensions"]
dependencies = [
"reactpy[asgi]>=2.0.0b10, <3.0.0",
"typing_extensions",
"jsonpointer==3.*",
]
dynamic = ["version"]
urls.Changelog = "https://reactive-python.github.io/reactpy-router/latest/about/changelog/"
urls.Documentation = "https://reactive-python.github.io/reactpy-router/latest/"
Expand All @@ -46,6 +50,7 @@ artifacts = ["/src/reactpy_router/static/"]

[tool.hatch.metadata]
license-files = { paths = ["LICENSE.md"] }
allow-direct-references = true

[tool.hatch.envs.default]
installer = "uv"
Expand All @@ -62,18 +67,27 @@ artifacts = []
#############################

[tool.hatch.envs.hatch-test]
extra-dependencies = ["pytest-sugar", "anyio", "reactpy[testing,starlette]"]
extra-dependencies = [
"pytest-sugar",
"anyio",
"playwright",
"uvicorn[standard]",
"asgiref",
"asgi-tools",
"servestatic",
"orjson",
]
randomize = true
matrix-name-format = "{variable}-{value}"

[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.9", "3.10", "3.11", "3.12"]
python = ["3.11", "3.12", "3.13", "3.14"]

[tool.pytest.ini_options]
addopts = """\
--strict-config
--strict-markers
"""
addopts = ["--strict-config", "--strict-markers"]
filterwarnings = """
ignore::DeprecationWarning:uvicorn.*
ignore::DeprecationWarning:websockets.*
"""

#######################################
# >>> Hatch Documentation Scripts <<< #
Expand Down Expand Up @@ -124,6 +138,10 @@ type_check = ["pyright src"]
detached = true

[tool.hatch.envs.javascript.scripts]
build = [
'bun install --cwd "src/js"',
'bun build "src/js/src/index.ts" --outfile "src/reactpy_router/static/bundle.js" --minify',
]
check = ['bun install --cwd "src/js"', 'bun run --cwd "src/js" check']
fix = ['bun install --cwd "src/js"', ' bun run --cwd "src/js" format']

Expand Down
Binary file modified src/js/bun.lockb
Binary file not shown.
4 changes: 1 addition & 3 deletions src/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
"check": "prettier --check . && eslint"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"eslint": "^9.13.0",
"eslint-plugin-react": "^7.37.1",
"prettier": "^3.3.3"
},
"dependencies": {
"preact": "^10.24.3"
"@reactpy/client": "^1.0.3"
}
}
18 changes: 10 additions & 8 deletions src/js/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import React from "preact/compat";
import ReactDOM from "preact/compat";
import { React } from "@reactpy/client";
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) {
export function bind(node: HTMLElement | Element | Node) {
return {
create: (type, props, children) =>
React.createElement(type, props, ...children),
render: (element) => {
ReactDOM.render(element, node);
create: (
type: string,
props: Record<string, unknown>,
children: React.ReactNode[],
) => React.createElement(type, props, ...children),
render: (element: HTMLElement | Element | Node) => {
React.render(element, node);
},
unmount: () => ReactDOM.unmountComponentAtNode(node),
unmount: () => React.render(null, node),
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/js/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export interface ReactPyLocation {
pathname: string;
search: string;
path: string;
query_string: string;
}

export interface HistoryProps {
Expand Down
4 changes: 2 additions & 2 deletions src/js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { ReactPyLocation } from "./types";

export function createLocationObject(): ReactPyLocation {
return {
pathname: window.location.pathname,
search: window.location.search,
path: window.location.pathname,
query_string: window.location.search,
};
}

Expand Down
28 changes: 11 additions & 17 deletions src/reactpy_router/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,25 @@
from uuid import uuid4

from reactpy import component, html, use_connection, use_ref
from reactpy.backend.types import Location
from reactpy.web.module import export, module_from_file
from reactpy.reactjs import component_from_file
from reactpy.types import Location

from reactpy_router.hooks import _use_route_state
from reactpy_router.types import Route

if TYPE_CHECKING:
from reactpy.core.component import Component
from reactpy.core.types import Key, VdomDict
from reactpy.types import Component, Key, VdomDict

History = export(
module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
("History"),
History = component_from_file(
Path(__file__).parent / "static" / "bundle.js", import_names="History", name="reactpy-router"
)
"""Client-side portion of history handling"""

Link = export(
module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
("Link"),
)
Link = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="Link", name="reactpy-router")
"""Client-side portion of link handling"""

Navigate = export(
module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
("Navigate"),
Navigate = component_from_file(
Path(__file__).parent / "static" / "bundle.js", import_names="Navigate", name="reactpy-router"
)
"""Client-side portion of the navigate component"""

Expand Down Expand Up @@ -74,7 +68,7 @@ def _link(attributes: dict[str, Any], *children: Any) -> VdomDict:
def on_click_callback(_event: dict[str, Any]) -> None:
set_location(Location(**_event))

return html._(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children))
return html(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children))


def route(path: str, element: Any | None, *routes: Route) -> Route:
Expand Down Expand Up @@ -113,12 +107,12 @@ def navigate(to: str, replace: bool = False, key: Key | None = None) -> Componen
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]
new_path = to.split("?", 1)[0]

def on_navigate_callback(_event: dict[str, Any]) -> None:
set_location(Location(**_event))

if location.pathname != pathname:
if location.path != new_path:
return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace})

return None
2 changes: 1 addition & 1 deletion src/reactpy_router/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def use_search_params(
A dictionary of the current URL's query string parameters.
"""
location = use_location()
query_string = location.search[1:] if len(location.search) > 1 else ""
query_string = location.query_string[1:] if len(location.query_string) > 1 else ""

# TODO: In order to match `react-router`, this will need to return a tuple of the search params \
# and a function to update them. This is currently not possible without reactpy core having a \
Expand Down
17 changes: 7 additions & 10 deletions src/reactpy_router/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
from logging import getLogger
from typing import TYPE_CHECKING, Any, Union, cast

from reactpy import component, use_memo, use_state
from reactpy.backend.types import Connection, Location
from reactpy.core.hooks import ConnectionContext, use_connection
from reactpy.types import ComponentType, VdomDict
from reactpy import component, use_connection, use_memo, use_state
from reactpy.core.hooks import ConnectionContext
from reactpy.types import Component, Connection, Location, VdomDict

from reactpy_router.components import History
from reactpy_router.hooks import RouteState, _route_state_context
Expand All @@ -18,8 +17,6 @@
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence

from reactpy.core.component import Component

from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router

__all__ = ["browser_router", "create_router"]
Expand Down Expand Up @@ -105,11 +102,11 @@ def _add_route_key(match: MatchedRoute, key: str | int) -> Any:
"""Add a key to the VDOM or component on the current route, if it doesn't already have one."""
element = match.element
if hasattr(element, "render") and not element.key:
element = cast(ComponentType, element)
element = cast(Component, element)
element.key = key
elif isinstance(element, dict) and not element.get("key", None):
element = cast(VdomDict, element)
element["key"] = key
element["attributes"]["key"] = key
return match


Expand All @@ -118,10 +115,10 @@ def _match_route(
location: Location,
) -> MatchedRoute | None:
for resolver in compiled_routes:
match = resolver.resolve(location.pathname)
match = resolver.resolve(location.path)
if match is not None:
return _add_route_key(match, resolver.key)

_logger.debug("No matching route found for %s", location.pathname)
_logger.debug("No matching route found for %s", location.path)

return None
9 changes: 6 additions & 3 deletions src/reactpy_router/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
if TYPE_CHECKING:
from collections.abc import Sequence

from reactpy.backend.types import Location
from reactpy.core.component import Component
from reactpy.types import Key
from reactpy.types import Key, Location

ConversionFunc: TypeAlias = Callable[[str], Any]
"""A function that converts a string to a specific type."""
Expand Down Expand Up @@ -42,7 +41,11 @@ class Route:

def __hash__(self) -> int:
el = self.element
key = el["key"] if is_vdom(el) and "key" in el else getattr(el, "key", id(el))
key = (
el["attributes"]["key"]
if is_vdom(el) and "attributes" in el and "key" in el["attributes"]
else getattr(el, "key", id(el))
)
return hash((self.path, key, self.routes))


Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def pytest_addoption(parser) -> None:

def pytest_sessionstart(session):
"""Rebuild the project before running the tests to get the latest JavaScript"""
subprocess.run(["hatch", "build", "--clean"], check=True)
env = os.environ.copy()
env.pop("HATCH_ENV_ACTIVE", None)
subprocess.run(["hatch", "build", "--clean"], check=True, env=env)
subprocess.run(["playwright", "install", "chromium"], check=True)


Expand Down
11 changes: 2 additions & 9 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def make_location_check(path, *routes):

@component
def check_location():
assert use_location().pathname == path
assert use_location().path == path
return html.h1({"id": name}, path)

return route(path, check_location(), *routes)
Expand All @@ -45,17 +45,10 @@ def sample():

await display.goto("/missing")

try:
root_element = await display.root_element()
except AttributeError:
root_element = await display.page.wait_for_selector(
f"#display-{display._next_view_id}", # type: ignore
state="attached",
)
root_element = await display.page.wait_for_selector("#app", state="attached")

assert not await root_element.inner_html()


async def test_nested_routes(display: DisplayFixture):
@component
def sample():
Expand Down