From c618058339702672006155f68ac4f3398b0c90d8 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 12 Feb 2026 09:02:32 -0700 Subject: [PATCH 01/13] Refactoring related to ReactPy v2.0.0 --- docs/examples/python/use_params.py | 2 +- docs/examples/python/use_search_params.py | 2 +- docs/src/learn/routers-routes-and-links.md | 2 +- pyproject.toml | 6 ++++- src/js/src/types.ts | 4 ++-- src/js/src/utils.ts | 4 ++-- src/reactpy_router/components.py | 26 +++++++--------------- src/reactpy_router/hooks.py | 2 +- src/reactpy_router/routers.py | 14 +++++------- src/reactpy_router/types.py | 4 ++-- tests/test_router.py | 2 +- 11 files changed, 29 insertions(+), 39 deletions(-) diff --git a/docs/examples/python/use_params.py b/docs/examples/python/use_params.py index 93a4f07..90aab5d 100644 --- a/docs/examples/python/use_params.py +++ b/docs/examples/python/use_params.py @@ -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 diff --git a/docs/examples/python/use_search_params.py b/docs/examples/python/use_search_params.py index faeba5e..8420d8c 100644 --- a/docs/examples/python/use_search_params.py +++ b/docs/examples/python/use_search_params.py @@ -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 diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index f185514..c42989f 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index f346125..a8d8eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/" diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 7144668..8b05409 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -1,6 +1,6 @@ export interface ReactPyLocation { - pathname: string; - search: string; + path: string; + query_string: string; } export interface HistoryProps { diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index e3f1dd5..797c0b5 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -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, }; } diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 6a751e7..53395b4 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -5,32 +5,22 @@ 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.types import Location +from reactpy.reactjs import component_from_file 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 Key, VdomDict, Component -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""" @@ -74,7 +64,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: @@ -118,7 +108,7 @@ def _navigate(to: str, replace: bool = False) -> VdomDict | None: def on_navigate_callback(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - if location.pathname != pathname: + if location.path != 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 2480440..70f33b5 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -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 \ diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index fad94eb..d51d1dc 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -7,9 +7,8 @@ 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.types import Component, VdomDict, Connection, Location from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context @@ -17,9 +16,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"] @@ -105,11 +101,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 @@ -118,10 +114,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 diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 755e244..0e8fb96 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from reactpy.backend.types import Location + from reactpy.types import Location from reactpy.core.component import Component from reactpy.types import Key @@ -42,7 +42,7 @@ 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)) diff --git a/tests/test_router.py b/tests/test_router.py index ddef544..22574bd 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -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) From af9a90dc87a449f7a84c0b6789275b7de6d8287e Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 12 Feb 2026 09:16:57 -0700 Subject: [PATCH 02/13] Apply formatting fixes --- src/reactpy_router/components.py | 12 ++++++++---- src/reactpy_router/routers.py | 3 ++- src/reactpy_router/types.py | 9 ++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 53395b4..2fd2811 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -5,22 +5,26 @@ from uuid import uuid4 from reactpy import component, html, use_connection, use_ref -from reactpy.types import Location 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.types import Key, VdomDict, Component + from reactpy.types import Component, Key, VdomDict -History = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="History", name="reactpy-router") +History = component_from_file( + Path(__file__).parent / "static" / "bundle.js", import_names="History", name="reactpy-router" +) """Client-side portion of history handling""" Link = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="Link", name="reactpy-router") """Client-side portion of link handling""" -Navigate = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="Navigate", name="reactpy-router") +Navigate = component_from_file( + Path(__file__).parent / "static" / "bundle.js", import_names="Navigate", name="reactpy-router" +) """Client-side portion of the navigate component""" diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index d51d1dc..9956577 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -8,7 +8,7 @@ from reactpy import component, use_memo, use_state from reactpy.core.hooks import ConnectionContext, use_connection -from reactpy.types import Component, VdomDict, Connection, Location +from reactpy.types import Component, Connection, Location, VdomDict from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context @@ -16,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Iterator, Sequence + from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router __all__ = ["browser_router", "create_router"] diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 0e8fb96..8e6f213 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -11,9 +11,8 @@ if TYPE_CHECKING: from collections.abc import Sequence - from reactpy.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.""" @@ -42,7 +41,11 @@ class Route: def __hash__(self) -> int: el = self.element - key = el["attributes"]["key"] if is_vdom(el) and "attributes" in el and "key" in el["attributes"] 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)) From 8e338955c19523663610e36d804be6d55ef8c5a6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:12:31 -0800 Subject: [PATCH 03/13] Use the reactjs bundle included withi `@reactpy/client` --- src/js/bun.lockb | Bin 106219 -> 108297 bytes src/js/package.json | 4 +--- src/js/src/components.ts | 18 ++++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/js/bun.lockb b/src/js/bun.lockb index ca1a00b587473dd48b0aaa96be3ef1ba9788feaf..a59a97a31dc2eab5bd7442b252bcad14b1f078c7 100644 GIT binary patch delta 23761 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 U?&3#QTF&61^aFvnp>=j^>oQWCCm6FH?) za#(UG!j$4uNJ=^hrAR3yKAreIpVxg|+o<*X{l1UypS>TRd)=?sbzQIPb-J$mdf%IS z-jNCyU$3w*I?81jM$Th##|ON4&+(5%^*1xFPnceB-7&Ey!~fdpwuw9U4?Z~Mo|TLG zL>0`qfvzQ=Du&_B%E?F{nP(WFd3ob9a-z{%9rBFa%yE+-ujgwRmB80f^mWi6@R=D? zCQ;E=C10)Ri=b|_kIKl%iW!${_<^qqewhh*0WLOHa0H%;j$$8B8uF`;VFZAF02&E8 zE;nXO=HwB^1O86GLy%Xde9WZDIT?nrNAaV_O&S%GpJlv;S=qi>(X5!v$s^PAU`6J* ziR1E&&G4dt9X4SgJxBpV&qima=jCOLGA3k9pF)qmgPi?Vp&u>J$j#3imzis1GM?*dI&m1E8#6t9Vy2OvGbSrNCpTj$8W3N{p}It2K<=pAOyhI3v$Ea=MM%8P zJk4^A&0)^Ow^l;VxQRLGqYXpW9RmAxUGct8RKcfpop|a(xi#@R!J{UR%*z=!DPuS0 zV?aiy=jLTYO-@FB?zqv@jlB(=04)r6{QL}*dG&&_{~C;gE8cw#4FeYH02h=*SO?+; zDiAUBrbRf04R7o?xEquTKf?N90gRcPH45_^^T0FUM?kqYQ79A}#Z4Uf=*c-T*agO3 zSL42dUiNDeO@j*z?{+jGS>DE=OubeJMU$tZ*0ti+YJzzf$P6BVB4potw3$5K+g7a zpop%wBMfAOQeYTMVm##3a~PBk?gVAYy#UI7*o1NlGR9;~TM7XOjLS{W$w{AX+z+0f zecsBECnq=_=R?j=4*{h?eL-2Hk*a?%C_Qt7veti42Au`vxHpm<`AgtAZb>BK&xvv% zpl2i1fHtk23DUsR!)l?|#IVHoG z+SV}YLp~9d6AuHW14m87p8|QGz>fY_JIB)3KObJCD)PB zq1QE4N%gz6m~~2z6u@#uK<8TLvb>z>)aVr48v6f-klz&X0v%!K zu$&3wAYjK3r@^a3hsu_@M!024M&{<`P0z|OHurQCPHbE?^uAMXj@i1?|9r&;5AS;J z{gA20nuP{OdI}r$d$;w~ynxEn#iG~eU%9i!57XZgtAkQRoX6jT1RIH%4nEKh!$+hK zh(k>o$q?eEU`U8#!Oa7Af$I%vMbJobIJjk?h0Pjk!i&{v1d265O~sb}?f_rIKwWtu zGh9;0c}A6yvb!%;x(`E&fOuWvSoI`Nu%BVXsgYR__u+SeZ@jgvU@rh`sycAt0%b+s3u>@`5?#a zSV3~B;Fx3CGhjM6j-%hGjb>taCwFDlpGHVo%I$7{$e1vHsiQl#RT}S0$2<>#(>k2^ z(UskhGN}v)4R;cM4N}_TBS$7+k6|w4DD?4>#W537>T@wrfkldA4*YZ(1bzc4Eudzf z5@{psQLIZCTUvqv>5%qnWqzOIn*!vpr2EXQy^^ysZ+`skqVTWz4WS>nRyhZGb869ndNOf=-+XT}l?L z`)xH?S{--;9Id(8v+mWL>0C04OTaM~aw_ea=Y2?jh7*g5q9`KC{TR45=mNJ9_k-Y4 zm70?3bi1)P50HIW!9&3@f1C=DM4e%(z-P-ytIHbO*T)?MVJFqqNgBI0s(I^%$Wj>u zX$t!4#kDxu9RyJ?B`QfxKy_^VtawJG#Hnr&%BV2Nfra3-u96tJKT%SdERogN*RLbg z$o(cDBcyaoD^a=%!X$L5!a@l=0gh8(TBBmgDyo7bL^ma^rlp=tNV`KSoko9Izn<-? zFD=K?R;6|W(!S_RY3UxD8R`UyE3ApsyQQPsA3_#S1$yajgVT&$*0QjsDh~a5FjUI7 zLaKvRqTGD}(oWFhBbQA~m}9Id(n68~r-GxKSo1~=IdN8)I{?Bq5JCnaSKiN%aF0`_ z;EO?9sSqmMQ^B!reYg%i8^FbZlPd?~oqg*nq#Q59AK1R0(}!6uHFFZA)MJV@HIplX zporJpbusqN!uN0B zluqe7UtQZxA?J2>3@^A2oDPFHfxn#KeMnm>X;obaZohEH7#Ukn8*rV`N0)`ObXGu` zqV?&Ko{Nxj%QDq0GHm|w4V}5gk2R7#Szz>{XFW+nJfA_DR%W2%S4^W@>xp~82uL}# zynA@of@4k?4es@Fr2AV)@1j1rT@HyTbsp=<$)+c}v0+&3!%;w0hQ$32q|yb}f!otW zXNh}tZ*Z&%nSW0KxQ=SPt7PUQkaDMSjs*U<8Ahz+cqB*!r$@*TiPU;=K@jRC>QiFe z7D#1zKtkoRAl#QAb(E(kCk3{Nc8bAK#R7jCl0kAh7%%!H`}*~e97`;dG~Se0OzRZefhHHq)|KrBKn@DxS273I%>{ce59Ko%-R3;CRh(3!4DW8PB#I;2dAq#3#9{wYE2f<_SujlMyGsqz1_<@bpM1%`hjIi`cUQ(vDCgcO8zF8^|e0 zb@T#d9vWdT?y63Hhk=vx$xVAJIOb5UT=(zbQe;ngW57*|&iQadcNc==Y^Go(90lj} zM8@1z;k7dZds^U}8ITRnDsXpk94sU%h~hzUK@iHgLEvJ8jApmi`fPu>296Zqm{iBK zkHN|KbNg^NMO`@@4ZRPXQ;6gafEy|erx z?pVx zQfk=E+16iylvB!m4N|9+t}3ZE#Hrlml+wy|COO^J?GK?7P;OzLfa4@Gd{8G(oPqUH zOLzBRd6&Ewl9EM$escMEo`+P|Dov~;%iudmo%&%Jq`+EdZL_xKgBwDpc+Pa62j|$r zeIw83)+qVv^ZyrY)DJ$*nI$;vr@Rn1fsjM zHpuk@$Do>m7C#9tMRK(GLvT*XupB)YjTrEO0c{B~rtZ{Yw}&T>;lh zHWl_y_D3ci1}^yz0oM;r;&iWM_hvBCBl+FQ&vLTP?@Ix_!6Z{fS<~l2C^M)`cg*?; zQt73LZ;%`SvvrVsWHBv)L=U1i88*))NXwR@Q|~Q$I;p9~xNs7dm?sZXEPk(+$_EXe zeUK(XD*Lg&?0XebAOHX@z3o3x)r!1`w9w%`l0>GM=A5!)~?a2=*?S@vF zFXUT({)sZLNV6P=T<{~F9~_CLB125V1tveF?2jnQ{%}BkNGV5XCGAZr>fT+;bcuF% z*TQK}RtyO%M}9~-W`M&=%J?Au@ISAMo|Ns= z6;I0XGY#?bJ)s^-XYmI;m{9rF^$x+)bVr@35q5vngmKm zTl1kmFWRVvwxIlw(!!2Peuv__fpUUgpfn^+(f*+LXADsMATs!nvfn+RG?0(f$&Uc# zyrZ}b*)SHAA5!v}ie@P}Ddl;JC*@_nqEnQdl$TTSgA+^#<%F|9Io_+}_bYkbIjUiv z67bg;_-8yuUbGulzrY{d8gNwi3}^u8W_D4O>R(oTSxV7X{9yZb)n1lTv_r{B+3z(_ znz={u#U^%7PW*;ycpH@bAyE7?{(~P($WhRSpqD|JxNA!O2Pi+JJ|c2>bKV4uDp0|O zs#K+@kC-^z*%fO;%7!}lL1XHJ(zu379sybfd<#&{odjADv^^+;(;bxKdV%7f(T6|E zp|m0m?WBEOYB}^%g8rbKV6fuv2BqSmpmcJSqT^Nj1W^1lCh|ua%4_x~_$f#t6pcD82ai($t{ zbR1c^*O$MJZ+7(cYwHda9s6&Uq@12_3_6q|{u~z~@+VlPk0_onQ0yEZC?Yd0(@$h) z4isZ21d0#ARTSY92a1r)Krv^cW#Z8axP#ylCRt`>Q8;O!m^Lv`oC6mq;wKLjO(q43 zCnsBGRdEK~NpPLBEYl+vWepS$PYx8nfU7Q2vj>XgtU&Q>wq*v3%izv~8<1m}wZxj7 zfnsTPpzzDJ%-SL?ccAE+6DYQUt0T<3f#Sd5GV(05t|$VxJ~vPV=UZldk)A(L49W`> z2f&33&y<0}KR;09PqEBKq8QxH{3;@9YIieIWKV^EQ{dlJ%WN#dr@_Ce@Nb%B-X@NL zI|wdex@9&Mh122RH24QDM#Rs6f79XL49jdT&VV}!uJcUGj2DY$!oL~t4_r%;It%{I zgnzRvGeKMicOKk;`z$j_tho>V&4PckEwi;qn+^Z&gMZ-K2(tkGfy*ec%yyy(-1^z@ z&uf_-M7kIL6~I4mslrnT|Ge<8&@%53#o%^=i@e`5JB#f5;a?&A1J^}_KLG#khkp-P zW;byR+(B>&4_c-r3Lk`j55PZgJw^N+`1c_En`4>1#TjrX!F7JfGSkGOhv45F_y?|^ zNSzD+9)f>!Epvdl4DLL*0S{Z|K(Xdw_%|2+&9ltGB5fZ0dl>$KyGNLhz&~&qk67lt zq6pmjdGPO1%N!=sABBI9z&~*5!ZRQKJqrKkTjoen3~nd5$j2-*Lu5Y&|K`I#aAQRH z0{HhB{99m|9kxMOej>ujL|7`dN zZmtMl2LG1Azh#y=PaFex5M07jmieeCd8lO@DJQ_ zVXlUM;4)TQ=F_4G-1=4UZ;fTH66tH;-)i^=ZjJD)g@0?{-&)IjMihhF2`=&(%Y0U3 zKLh{P!as1&i|}>u?-}^F&N5#R$G{x~m+-7*ZV-jf!oPL!58Or({~Y{#7XCeFnVZEK za3{fae%>;R#G>cn-*fN}+*Xmg9{xQK|JGaPHgOr;d2j<>u*@A|%?t2vJ^XvoGG7&G zFT%eU;2*eM!rTD=z-4T(%-2N`xb-i>zn3g?uSkCh{%wGN;EIK3Bm8>_{%y3(H$*YG zo!}xjS>^$ey$Swpgn!`P7U7%W-zNCC*)k7_W8e;gOW0zW?})-J@NaW~2-u&>=MtuI zz3O$)!y>*2u5N*=MV5I~oB?-oOMvL~au4&ESoAVnErP2rTjp_*x)rXz3|F^W=7-`k zxbxr!ykeOji8Zgl)va)Kn`M41(ze0XSKunRPldT1u7b=s!POm>`GrW| z0av%fRd8p7XD3|U0atfg=9i)v+)i+juUh6gk^L%M-3eF0eIvqOgR8H?)z>WZf;a~5 zAh?8GmU&SW?t-hYA(i017xBB{-!AyK+cJL;XTY5V*ZFnJyo^6?gMYi>AGn`I>K^#_ zI{e#XnZJn3;Ld{^u-7uLi8XuS-yZn4&oX}%Y5U;cUib&@cVQO8KX4hvmg`SEDJ$+J zR__Zgcwu6X**9Xw&J)|~?2MjsI`F4U;-R1NdoBF4wNWMO#GqLXPk;Ddzxt03zH;G{ z1EYJbo3Lf(PXVn?EvfkIo2wQVo&S&X(8kHq4SC9Oe)w))SNr(p3Bk!1JrDckeG_rQ zwX4RqOz)yL+uB}v));c6PtU$VJ;fbCwd>ERv!ef&1y59N(edZ|Hx&DAelc|Mmota1 z>?wK{hZMBDXW_y_vo^Z+ZjHOr?Bv7^)vCrWeJ*rzv*fTEm->EF>51jNqmmAtOBitE z-05|{9&X?4Mx&yz*}X2kAG>qw{VTTk@Q)C64VgT2sE_Z1QT@9AQRi^_=7(My_h;yn z8~oZ``y{ns!i7s=2Ny(Dd3jje`U_9I)#>)y`JW|Z-+S)o!}-5;>0k3=hYfS{M`kBk z;SFcu_W@1C@S47^Ttm#>AM0xG+PZ1K%cq_Xo+04rn^(`oFYWqo>3yn*BM0se`~knM zs)R1Tf>zmDwEvn(MSgzx)qE9EwEr8aw!KSi+J9nega0h8=sM9xymlW!n)_7h#=4v!Rcf1?Gcwj*QPGT(k5;bVa6a^E zFTh^4?K2h4>h1WL-97KIf#2PdTsJg;e3Iy1%QMDuend*gkIz+hkia2L=A;8Us|KsVq{fX~SA z+g|>ykdXp(1o%KM13*<57-MiT78nPl10w;J_H95@pcxPY!~)HMI3ONijq|zbE5J5@ z&szD%=0yN&{Uu-{unCxsNoN53JqLfek^zhY()s(9;kZZx`T{+HE_B#ofiRRG&pEdRZcwcw~cogUkGzDUSb{O|DXm9l4gMAhu|1vxU@{Ry2uPeaE zt^6zO9zah3o*Q^>Z2Zd01-RfdVy@rSpSP%aieOMVY{ANT+` z0el2}0(=T^;hh3b180G+fUkiIz_-9f;5*=Z6REg_iywii0PCF-=xg#s2UuMLpqy)m zRDc zGFSAbY`%tozZ;+j%-cNxbH+5WuTE8ET&DvgflOc&kO6R88xM>F=;;_>G%!|OlQw1k znJ0P?3ot*CKpv0-2NVPQfW6HB zd|)B44%h?i26h3D0j~kC0`~)t0*?cQzye?gupM|9*ao};ya;RsY+x1eGEf9O2W$a0 z0vmuAfb~GZ^Z4-$uohSYtOiyBPXIJdTRa!nG{OtefCqqwfH}Z}0A=K9jCP1Top=OT z1kgz>Qw|l#&g`Tc%X-MpS{VljU^(zKzzLrORsc@{%K&=41Yq0JGL&P;Qy&fIc&1@4 zU8G`8%5ZUlzw=tRJA-Z>Lm7?dcwRH-OeJOXnrV9uDBGs5XLB-k)=HQ^Z7pSt483^? z(1WOW6QD!78P}Xdx3fPdq#nw&VLHmJot>(!fXdjwBBAAs1_x=mR>~^oM9lB&0K?3X za;z7iQaz5vSl%^PLK&R}B@?ldS-EWFb(v1G52M7%Xdpdg^=||i33_t~I0(EA(3r!( zyTCiZ5kTwH(WbMrne4IxUY`Jt0}S5>fDY$}xMo;cK)UTC#k24(0-vb1Z$Y_?F97U+ z9{2{J@mw*~^&n8l{GS890vOsa0oK4-fJ#3GxQ5RFoScT82EG7J0iOe(0iOa~vL^v; zgkG{t6ZQO^ynvptBRiD!>YuLZkuFq5h;_mK1ly>Kl%9SKXgQr=f6apyNCCr0C3+Ao zW+z5P=Zyu(qTB;K1h5uf1z7oy1G*-@$Mpt)`DGuWK;f;{t%K}V zIc1!LwQ&QGj~q-yCI~<9S=-y56kB3_&)WXK?%KUwB}gY(*j>FO#A)X zW{WWXjZ1avxa;tj%^JNqRQ8DH-;(E;c7ss#n2#QT=+Xbv8BLmoC69KQ{PPq#v&^)! zV$G1mwcu&Twe=%@Vu zMz`m82hO7Z2XJt=b9V(Uj`$KI5*b0~&|Vuo))t?g9(8;{0$N%@SA4v2!?fGiF{2}D zy5y0}>jkYxUAqTt*yO5uS@y)dxW~He`E?K%{h#?yh1afDsa+KpdO%Y$y1e7E_n}9k zet$s5>miyo*O=+^|q@A z9PNp*35I@q!R_%@|IorOUP23W(z{q6`*b5SxIQlRD-YTab?yG(2mdcrr$n4Fee8u{ zW)D}ok9{f(snPFjSg_{h0e{|ibO3rXBrwwJV>hdd@Xq(K_k^1v_RzX!FV`v`dlQA5 zeC&gD%@(dbKK37^hkWd~dQhida&cE^*JJfBZ-r;tx?g?l+4V3|W!&ZGYcISFd<|dw zUCQhE+FwJ1YqhT(S|9X9U%OX*$hZ31W68hnYp(zww%S)7A-!7<6)f&J=A}qELks$k zp6{VYV);GvD>?@6z2vvz)b~x~*!b8ae1ocJ*J}Xn`sE#m9-CFAmg~c5E)%h!a=fm? z?%Mz!>eq1GmG(}r^q^PXhK@uhV6VCD)eR8Z&6Vs+(B(?0Z2O0sJ;L;RHzwXzZ^VmF zjOgt$u{5PltdFd4_%B7Le9ahi(4Z}DihK={wpK(~e$G+OoY>}wnq%k5p zqFqYms>$fd#RY%$yETptdhxw(S5}>KnX}_jysdC5@v(HXiKoL7ndJ9qgjU zVwO79?Zu4{2&W)JTuX!P-x`^(xR%zmw?x2QT>|TB+SkE{>Gx)A2p_cew>dW=pot|7 zO-E|kJt8reext_4fCjZI-yhkc)T4p5ZEs{5oe1HPNG#+u#IFuQ_m2xXEDE~+W|=Yu zExAnXKIoCSM(JwTD{Fe)Is4mUcEpIgWciD6cc{HGs&t*%^%|R@iT||NoNa&kvWPuB z#q(D%MN+KXZ{X0R#^&g+MhIm z6(9K6O>RSbIg2n+zhR`olG5^!YSEZgEnRzPG-9y&AJ(rkRq~RUC&J&T#=Rph7Ml_7 z``xCd zx9l=QNJGN@ae1w12Fv$0u-|J2b>48hUJNQ>VYri~xfO3)+4Gr~xutVew8mKuEmaYx zHubv>ow2yPC0knXHo32%of!jNFllv+`BXWp0NX``y{|WNmlk2~;Vn_Wm7&vtKU#Nq zC;JhpQAS?B`r=3LJAI;~etk-{pkj|i*a6L<UU?nm-wC8>z&~4 zrKcca8$dPg%&WW^x;?Q$mq}?Y4J(r+oP4uklyYlR?7Gci4ok=rZid@nUca|w}DBMcF~!&6@RE8u|?;AJ?*Dt9eI%rTW6GAyM{q_8lK( z?`Z=E^cy}Bd(3DWyXuEn^lXK!C*W&Als&u?dg!;POp9rHaaMHbd8sR&F1`?Ddt0D~ zegVt+pQgnnHmdC~jik-U>zSWGmPY2BP9QoN^TwWS4-^$ zi5Rby>hWrveHD#i`u#q;rhS`s^3FdysBmL>d=+7DX^&v)7xoyfuS{9n<9+&<44C@J*Gs~#svfxKI@KHOhmva? zYyW}HYJJG$IC=V8kup=IGUOkUgSfq$T)smvQ*J6fN)~A8N-94}!yDTxxDnm7Fa1S@ zZ_9FY>xR-2P?O3A+uOmcZDNaSiy8Fxq|z6r-_-PB*uKyDz4GR|(r7yyO|T!5s1`W3 z%fHoii#RP}$(?3R-#A0hB?A)dhoz;9W?x%W)GaaBo3SoTy|E2x=kzV>g|=S5eQ8VO zPbN;Av;8=%=a~dmHSgv%5vJeIlyiN~(5`n6t_KfdzhpzW2jWsc<;wM}dLJKlQqd$?9FtCU#()%icDDnK0dn$cNNd!0Ql z6$ggDB(~&6BU?(#QQv>c9S~OjUHq?kfyetg!fO4tvB;j`tKaPKS#@c(ytV2?Aierl z7#U@M)=lR?23EKHUHO)pu8UX&z}ZZ7W$62@zQ2`Uk4_25MKG+by@@wN{idx4wqzWQ zZrz(lM&5++Eo*99`)nuNru8efTpwk7?bH@cWe<7Md#jxt*BL$Z%eT(2f28V(6BV*t z=Hu<1CkD58rQNtiEZ1u3u5e4M8WdK}(+@0_{3v_HoieI&o9E%|J@Pt0dg=VlW`cm? z7AY4Crj_eQk4A!5cYzkCONdJ!N|s01mng?JdEF{AE6!SNyCS0hc-G2754Gfi>tjY2Q~@^@*C%O{EZl(JvXB_|)$3Md^F?qlMos zv7+>u3}>!(L4}m&Q@A%ZN;?99`*7z1UzNv60mU9xr9rAv4 z@@rIZ=WRcK*PwKf|7#}}Fh;85Yp@@bmRDT=Q@JWi``ez*eMwg? zH{*Y}&FJYgb@mf^d)1yPk$tQe_ST#38L}IidGc`KcG12!C{~O7B2-4>-z+G0N!0Jw zd-UM!${!s*^}ACst?`|)oVy0^pw1E2j_Ok;32Lsea>`qter`G=sKY=@ee2iXi%QOu zj?*D7x#)uZP$&9^e9Jc+{pBW6zjiM*)-^VB%(XDb8Gd;w|LX-}C2#2}#AVLmiTdq* z#(RC<{IkoK`nwPeDQ7n?*U9)2CvS2JUyQms&t?yH+Iw`zD=e(!2;?}zg58)bh> zs?XH=jfBUaThqT!OItr~#B4Zu_U(^v?qj>zt9km??<~B2@8bh6zJKROYIX!y-O{yd zdRgk~Q@s63GiOUZzh;yZWU|Y~Gj|YWA$0$yqV%Y8MSQ!#pY+7@)4~ f)fr}F^sSw9(V=8yZcKbaY!ZJp1GOUk;L`sCCIdsT diff --git a/src/js/package.json b/src/js/package.json index 313fab9..53f9f21 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -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" } } diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 4712637..1aaff62 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -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, + 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), }; } From bbbf24f6a905b7c75514196b7e96ea378dfe5930 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:13:27 -0800 Subject: [PATCH 04/13] simplify `test_simple_router` not rely on `_next_view_id`. --- tests/test_router.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_router.py b/tests/test_router.py index 22574bd..f3723a5 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -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(): From da62c03b321cc3be9e0c34cac4a117468013d5bc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:15:49 -0800 Subject: [PATCH 05/13] remove version mismatched reactpy dependency in test environment, and add missing deps --- pyproject.toml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a8d8eb9..35de6fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,16 @@ 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}" From 112512cc841348e12d838c58e0e5ce3e0ee3fa33 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:16:42 -0800 Subject: [PATCH 06/13] Make hatch happy with re-building javascript within subprocesses --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3463e16..4355990 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) From 4658004d3da55d03619ce0142133d2442ad152d9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:17:38 -0800 Subject: [PATCH 07/13] clean-up, sync settings with core, and bump versions --- pyproject.toml | 25 +++++++++++++++---------- src/reactpy_router/__init__.py | 2 +- src/reactpy_router/routers.py | 4 ++-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35de6fe..9e5eef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -29,7 +29,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "reactpy[asgi]>=2.0.0b10, <3.0.0", + "reactpy[asgi]>=2.0.0b10, <3.0.0", "typing_extensions", "jsonpointer==3.*", ] @@ -50,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" @@ -77,16 +78,16 @@ extra-dependencies = [ "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 <<< # @@ -137,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'] diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index add1e5d..5736183 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.0.0" +__version__ = "3.0.0b1" from reactpy_router.components import link, navigate, route diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 9956577..b639c47 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -6,8 +6,8 @@ from logging import getLogger from typing import TYPE_CHECKING, Any, Union, cast -from reactpy import component, use_memo, use_state -from reactpy.core.hooks import ConnectionContext, use_connection +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 6aeaef6a1d098ba2880a537c1c3120d9407ef795 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:27:29 -0800 Subject: [PATCH 08/13] improve variable name clarity for `path` in `navigate` component --- src/reactpy_router/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 2fd2811..d16af57 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -107,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.path != pathname: + if location.path != new_path: return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace}) return None From 1ae5b52d83badc6678c94349828ff2375dac51c3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:31:48 -0800 Subject: [PATCH 09/13] Add changelog entry --- CHANGELOG.md | 4 +++- src/reactpy_router/__init__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc200b..439508e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 5736183..add1e5d 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0b1" +__version__ = "2.0.0" from reactpy_router.components import link, navigate, route From ea1d566437e1bce9f781b0530df5fea861890b93 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:36:18 -0800 Subject: [PATCH 10/13] Attempt to fix "Test Python" CI workflow not running for this PR --- .github/workflows/test-docs.yml | 4 ++-- .github/workflows/test-javascript.yml | 2 +- .github/workflows/test-python.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 0a81d18..78266a4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -6,9 +6,9 @@ on: - main pull_request: branches: - - main + - "*" schedule: - - cron: "0 0 * * *" + - cron: "0 0 * * 0" jobs: docs: diff --git a/.github/workflows/test-javascript.yml b/.github/workflows/test-javascript.yml index 5f62c0e..ff610d1 100644 --- a/.github/workflows/test-javascript.yml +++ b/.github/workflows/test-javascript.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - main + - "*" jobs: javascript: diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index bbac572..970c509 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -6,9 +6,9 @@ on: - main pull_request: branches: - - main + - "*" schedule: - - cron: "0 0 * * *" + - cron: "0 0 * * 0" jobs: python-source: From aca3283c60c39df260bdd1ce7a783a6cd4da0184 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:41:39 -0800 Subject: [PATCH 11/13] GitHub now requires workflow names to be unique --- .../workflows/{test-python.yml => check.yml} | 37 +++++++++++++- ...h-develop-docs.yml => publish-develop.yml} | 4 +- .github/workflows/publish-latest-docs.yml | 29 ----------- .github/workflows/publish-python.yaml | 27 ---------- .github/workflows/publish-release.yml | 49 +++++++++++++++++++ .github/workflows/test-docs.yml | 33 ------------- .github/workflows/test-javascript.yml | 25 ---------- 7 files changed, 87 insertions(+), 117 deletions(-) rename .github/workflows/{test-python.yml => check.yml} (73%) rename .github/workflows/{publish-develop-docs.yml => publish-develop.yml} (92%) delete mode 100644 .github/workflows/publish-latest-docs.yml delete mode 100644 .github/workflows/publish-python.yaml create mode 100644 .github/workflows/publish-release.yml delete mode 100644 .github/workflows/test-docs.yml delete mode 100644 .github/workflows/test-javascript.yml diff --git a/.github/workflows/test-python.yml b/.github/workflows/check.yml similarity index 73% rename from .github/workflows/test-python.yml rename to .github/workflows/check.yml index 970c509..6f9e1fc 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/check.yml @@ -1,4 +1,4 @@ -name: Test +name: Check on: push: @@ -95,3 +95,38 @@ jobs: run: pip install --upgrade pip hatch uv - name: Run Python type checker run: hatch run python:type_check + + javascript: + 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 Tests + run: hatch run javascript:check + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - 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: Check documentation links + run: hatch run docs:linkcheck + - name: Check docs build + run: hatch run docs:build + - name: Check docs examples + run: hatch fmt docs --check diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop.yml similarity index 92% rename from .github/workflows/publish-develop-docs.yml rename to .github/workflows/publish-develop.yml index a1434ba..770c0b1 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop.yml @@ -1,10 +1,10 @@ -name: Publish Develop Docs +name: Publish Develop on: push: branches: - main jobs: - publish-develop-docs: + docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml deleted file mode 100644 index 0a1e996..0000000 --- a/.github/workflows/publish-latest-docs.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish Latest Docs -on: - release: - types: [published] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - uses: actions/setup-python@v5 - with: - python-version: 3.x - - name: Install dependencies - run: | - pip install --upgrade pip hatch uv - - name: Configure Git - run: | - git config user.name github-actions - git config user.email github-actions@github.com - - name: Publish ${{ github.event.release.name }} Docs - run: hatch run docs:deploy_latest ${{ github.ref_name }} - concurrency: - group: publish-docs diff --git a/.github/workflows/publish-python.yaml b/.github/workflows/publish-python.yaml deleted file mode 100644 index a2228a7..0000000 --- a/.github/workflows/publish-python.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Publish Python - -on: - release: - types: [published] - -jobs: - publish-python: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Install dependencies - run: pip install --upgrade pip hatch uv - - name: Build Package - run: hatch build --clean - - name: Publish to PyPI - env: - HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} - HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} - run: hatch publish --yes diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..5f43163 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,49 @@ +name: Publish Release +on: + release: + types: [published] + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install dependencies + run: pip install --upgrade pip hatch uv + - name: Configure Git + run: | + git config user.name github-actions + git config user.email github-actions@github.com + - name: Publish ${{ github.event.release.name }} Docs + run: hatch run docs:deploy_latest ${{ github.ref_name }} + concurrency: + group: publish-docs + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: pip install --upgrade pip hatch uv + - name: Build Package + run: hatch build --clean + - name: Publish to PyPI + env: + HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} + run: hatch publish --yes diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml deleted file mode 100644 index 78266a4..0000000 --- a/.github/workflows/test-docs.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Test - -on: - push: - branches: - - main - pull_request: - branches: - - "*" - schedule: - - cron: "0 0 * * 0" - -jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - 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: Check documentation links - run: hatch run docs:linkcheck - - name: Check docs build - run: hatch run docs:build - - name: Check docs examples - run: hatch fmt docs --check diff --git a/.github/workflows/test-javascript.yml b/.github/workflows/test-javascript.yml deleted file mode 100644 index ff610d1..0000000 --- a/.github/workflows/test-javascript.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Test - -on: - push: - branches: - - main - pull_request: - branches: - - "*" - -jobs: - javascript: - 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 Tests - run: hatch run javascript:check From 6af6cc51dbe93c8be2a614150d987130ae2cc0f4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:59:22 -0800 Subject: [PATCH 12/13] fix link failures --- docs/examples/python/nested_routes.py | 4 +- docs/mkdocs.yml | 2 +- docs/src/learn/routers-routes-and-links.md | 2 +- pyproject.toml | 3 +- src/reactpy_router/hooks.py | 2 +- src/reactpy_router/resolvers.py | 4 +- src/reactpy_router/routers.py | 8 ++-- src/reactpy_router/types.py | 8 ++-- tests/test_resolver.py | 8 ++-- tests/test_router.py | 49 +++++++++++----------- 10 files changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/examples/python/nested_routes.py b/docs/examples/python/nested_routes.py index e146d90..bc27274 100644 --- a/docs/examples/python/nested_routes.py +++ b/docs/examples/python/nested_routes.py @@ -46,12 +46,12 @@ def all_messages(): messages = [] for msg in last_messages.values(): - _link = link( + msg_link = link( {"to": f"/messages/with/{'-'.join(msg['with'])}"}, f"Conversation with: {', '.join(msg['with'])}", ) msg_from = f"{'' if msg['from'] is None else '🔴'} {msg['message']}" - messages.append(html.li({"key": msg["id"]}, html.p(_link), msg_from)) + messages.append(html.li({"key": msg["id"]}, html.p(msg_link), msg_from)) return html.div( html.h1("All Messages 💬"), diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ad4ab0f..a15d438 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -90,7 +90,7 @@ plugins: handlers: python: paths: ["../"] - import: + inventories: - https://reactpy.dev/docs/objects.inv - https://installer.readthedocs.io/en/stable/objects.inv options: diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index c42989f..a6233ce 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -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.use_location] hook. + by checking the `reactpy.use_location` hook. Here's a basic example showing how to use `#!python browser_router` with two routes. diff --git a/pyproject.toml b/pyproject.toml index 9e5eef0..c476f8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ template = "docs" dependencies = [ "mkdocs", "mkdocs-git-revision-date-localized-plugin", - "mkdocs-material==9.4.0", + "mkdocs-material", "mkdocs-include-markdown-plugin", "mkdocs-spellcheck[all]", "mkdocs-git-authors-plugin", @@ -162,6 +162,7 @@ lint.extend-ignore = [ "PLR2004", # Magic value used in comparison "SIM115", # Use context handler for opening files "SLF001", # Private member accessed + "DOC201", # 'Returns:' section in docstring is missing ] lint.preview = true diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 70f33b5..0431d11 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -5,7 +5,7 @@ from reactpy import create_context, use_context, use_location -from reactpy_router.types import RouteState # noqa: TCH001 +from reactpy_router.types import RouteState # noqa: TC001 if TYPE_CHECKING: from reactpy.types import Context diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index 58e7b7f..3ab6261 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -73,9 +73,7 @@ def resolve(self, path: str) -> MatchedRoute | None: if match: # Convert the matched groups to the correct types params = { - parameter_name[len("_numeric_") :] - if parameter_name.startswith("_numeric_") - else parameter_name: self.converter_mapping[parameter_name](value) + parameter_name.removeprefix("_numeric_"): self.converter_mapping[parameter_name](value) for parameter_name, value in match.groupdict().items() } return MatchedRoute(self.element, params, path) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index b639c47..8b3032b 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -4,7 +4,7 @@ from dataclasses import replace from logging import getLogger -from typing import TYPE_CHECKING, Any, Union, cast +from typing import TYPE_CHECKING, Any, cast from reactpy import component, use_connection, use_memo, use_state from reactpy.core.hooks import ConnectionContext @@ -60,7 +60,7 @@ def router( a custom routing engine.""" old_connection = use_connection() - location, set_location = use_state(cast(Union[Location, None], None)) + location, set_location = use_state(cast("Location | None", None)) resolvers = use_memo( lambda: tuple(map(resolver, _iter_routes(routes))), dependencies=(resolver, hash(routes)), @@ -102,10 +102,10 @@ 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(Component, element) + element = cast("Component", element) element.key = key elif isinstance(element, dict) and not element.get("key", None): - element = cast(VdomDict, element) + element = cast("VdomDict", element) element["attributes"]["key"] = key return match diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 8e6f213..e2032e7 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -2,17 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeAlias, TypedDict, TypeVar from reactpy.core.vdom import is_vdom -from typing_extensions import Protocol, Self, TypeAlias +from typing_extensions import Protocol if TYPE_CHECKING: from collections.abc import Sequence - from reactpy.core.component import Component - from reactpy.types import Key, Location + from reactpy.types import Component, Key, Location ConversionFunc: TypeAlias = Callable[[str], Any] """A function that converts a string to a specific type.""" diff --git a/tests/test_resolver.py b/tests/test_resolver.py index cf1a17e..ee172db 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -10,7 +10,7 @@ def test_resolve_any(): resolver = ReactPyResolver(route("{404:any}", "Hello World")) - assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$") + assert resolver.parse_path("{404:any}") == re.compile(r"^(?P<_numeric_404>.*)$") assert resolver.converter_mapping == {"_numeric_404": str} assert resolver.resolve("/hello/world") == MatchedRoute( element="Hello World", params={"404": "/hello/world"}, path="/hello/world" @@ -22,7 +22,7 @@ class CustomResolver(ReactPyResolver): param_pattern = r"<(?P\w+)(?P:\w+)?>" resolver = CustomResolver(route("<404:any>", "Hello World")) - assert resolver.parse_path("<404:any>") == re.compile("^(?P<_numeric_404>.*)$") + assert resolver.parse_path("<404:any>") == re.compile(r"^(?P<_numeric_404>.*)$") assert resolver.converter_mapping == {"_numeric_404": str} assert resolver.resolve("/hello/world") == MatchedRoute( element="Hello World", params={"404": "/hello/world"}, path="/hello/world" @@ -31,7 +31,7 @@ class CustomResolver(ReactPyResolver): def test_parse_path(): resolver = ReactPyResolver(route("/", None)) - assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$") + assert resolver.parse_path("/a/b/c") == re.compile(r"^/a/b/c$") assert resolver.converter_mapping == {} assert resolver.parse_path("/a/{b}/c") == re.compile(r"^/a/(?P[^/]+)/c$") @@ -61,7 +61,7 @@ def test_parse_path(): def test_parse_path_unkown_conversion(): resolver = ReactPyResolver(route("/", None)) - with pytest.raises(ValueError, match="Unknown conversion type 'unknown' in '/a/{b:unknown}/c'"): + with pytest.raises(ValueError, match=r"Unknown conversion type 'unknown' in '/a/{b:unknown}/c'"): resolver.parse_path("/a/{b:unknown}/c") diff --git a/tests/test_router.py b/tests/test_router.py index f3723a5..f1922be 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -49,6 +49,7 @@ def sample(): assert not await root_element.inner_html() + async def test_nested_routes(display: DisplayFixture): @component def sample(): @@ -92,8 +93,8 @@ def sample(): await display.show(sample) for link_selector in ["#root", "#a", "#b", "#c"]: - _link = await display.page.wait_for_selector(link_selector) - await _link.click(delay=CLICK_DELAY) + link_ = await display.page.wait_for_selector(link_selector) + await link_.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -174,8 +175,8 @@ def sample(): 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) + link_ = await display.page.wait_for_selector(link_selector) + await link_.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -205,8 +206,8 @@ def sample(): 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) + link_ = await display.page.wait_for_selector(link_selector) + await link_.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -233,8 +234,8 @@ def sample(): await display.show(sample) await display.page.wait_for_selector("#root") - _link = await display.page.wait_for_selector("#root") - await _link.click(delay=CLICK_DELAY) + link_ = await display.page.wait_for_selector("#root") + await link_.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#success") @@ -245,8 +246,8 @@ def sample(): await display.show(sample) - _link = await display.page.wait_for_selector("#root") - assert "class1" in await _link.get_attribute("class") + link_ = await display.page.wait_for_selector("#root") + assert "class1" in await link_.get_attribute("class") async def test_link_href(display: DisplayFixture): @@ -256,8 +257,8 @@ def sample(): await display.show(sample) - _link = await display.page.wait_for_selector("#root") - assert "/a" in await _link.get_attribute("href") + link_ = await display.page.wait_for_selector("#root") + assert "/a" in await link_.get_attribute("href") async def test_ctrl_click(display: DisplayFixture, browser: Browser): @@ -270,8 +271,8 @@ def sample(): await display.show(sample) - _link = await display.page.wait_for_selector("#root") - await _link.click(delay=CLICK_DELAY, modifiers=["Control"]) + link_ = await display.page.wait_for_selector("#root") + await link_.click(delay=CLICK_DELAY, modifiers=["Control"]) browser_context = browser.contexts[0] if len(browser_context.pages) == 1: new_page: Page = await browser_context.wait_for_event("page") @@ -298,8 +299,8 @@ def sample(): ) await display.show(sample) - _button = await display.page.wait_for_selector("button") - await _button.click(delay=CLICK_DELAY) + 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() @@ -325,10 +326,10 @@ def sample(): ) 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) + 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() @@ -353,10 +354,10 @@ def sample(): ) 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) + 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) From 36a27a91958834f20f8eb00f93de71a249f17830 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:00:34 -0800 Subject: [PATCH 13/13] expand pythion version in GitHub test matrix --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6f9e1fc..8dd2569 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2