Skip to content

Commit 6321231

Browse files
committed
Better return type for Resolver().resolve
1 parent 2d79831 commit 6321231

File tree

5 files changed

+51
-50
lines changed

5 files changed

+51
-50
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Don't forget to remove deprecated code on each major release!
2525
- Set minimum ReactPy version to `1.1.0`.
2626
- `link` element now calculates URL changes using the client.
2727
- Refactoring related to `reactpy>=1.1.0` changes.
28+
- Determination of the browser's initial URL is now deterministic.
2829

2930
### Fixed
3031

src/reactpy_router/resolvers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from __future__ import annotations
22

33
import re
4-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING
55

66
from reactpy_router.converters import CONVERTERS
7+
from reactpy_router.types import MatchedRoute
78

89
if TYPE_CHECKING:
910
from reactpy_router.types import ConversionInfo, ConverterMapping, Route
@@ -70,7 +71,7 @@ def parse_path(self, path: str) -> re.Pattern[str]:
7071

7172
return re.compile(pattern)
7273

73-
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
74+
def resolve(self, path: str) -> MatchedRoute | None:
7475
match = self.pattern.match(path)
7576
if match:
7677
# Convert the matched groups to the correct types
@@ -80,5 +81,5 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
8081
else parameter_name: self.converter_mapping[parameter_name](value)
8182
for parameter_name, value in match.groupdict().items()
8283
}
83-
return (self.element, params)
84+
return MatchedRoute(self.element, params, path)
8485
return None

src/reactpy_router/routers.py

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from dataclasses import replace
66
from logging import getLogger
7-
from typing import TYPE_CHECKING, Any, Literal, cast
7+
from typing import TYPE_CHECKING, Any, cast
88

99
from reactpy import component, use_memo, use_state
1010
from reactpy.backend.types import Connection, Location
@@ -14,6 +14,7 @@
1414
from reactpy_router.components import History
1515
from reactpy_router.hooks import RouteState, _route_state_context
1616
from reactpy_router.resolvers import StarletteResolver
17+
from reactpy_router.types import MatchedRoute
1718

1819
if TYPE_CHECKING:
1920
from collections.abc import Iterator, Sequence
@@ -57,36 +58,27 @@ def router(
5758
*routes: Route,
5859
resolver: Resolver[Route],
5960
) -> VdomDict | None:
60-
"""A component that renders matching route(s) using the given resolver.
61+
"""A component that renders matching route using the given resolver.
6162
62-
This typically should never be used by a user. Instead, use `create_router` if creating
63+
User notice: This component typically should never be used. Instead, use `create_router` if creating
6364
a custom routing engine."""
6465

65-
old_conn = use_connection()
66-
location, set_location = use_state(old_conn.location)
67-
first_load, set_first_load = use_state(True)
68-
66+
old_connection = use_connection()
67+
location, set_location = use_state(cast(Location | None, None))
6968
resolvers = use_memo(
7069
lambda: tuple(map(resolver, _iter_routes(routes))),
7170
dependencies=(resolver, hash(routes)),
7271
)
73-
74-
match = use_memo(lambda: _match_route(resolvers, location, select="first"))
72+
route_element = None
73+
match = use_memo(lambda: _match_route(resolvers, location or old_connection.location))
7574

7675
if match:
77-
if first_load:
78-
# We need skip rendering the application on 'first_load' to avoid
79-
# rendering it twice. The second render follows the on_history_change event
80-
route_elements = []
81-
set_first_load(False)
82-
else:
83-
route_elements = [
84-
_route_state_context(
85-
element,
86-
value=RouteState(set_location, params),
87-
)
88-
for element, params in match
89-
]
76+
# Skip rendering until ReactPy-Router knows what URL the page is on.
77+
if location:
78+
route_element = _route_state_context(
79+
match.element,
80+
value=RouteState(set_location, match.params),
81+
)
9082

9183
def on_history_change(event: dict[str, Any]) -> None:
9284
"""Callback function used within the JavaScript `History` component."""
@@ -96,8 +88,8 @@ def on_history_change(event: dict[str, Any]) -> None:
9688

9789
return ConnectionContext(
9890
History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value]
99-
*route_elements,
100-
value=Connection(old_conn.scope, location, old_conn.carrier),
91+
route_element,
92+
value=Connection(old_connection.scope, location or old_connection.location, old_connection.carrier),
10193
)
10294

10395
return None
@@ -110,9 +102,9 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]:
110102
yield parent
111103

112104

113-
def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
105+
def _add_route_key(match: MatchedRoute, key: str | int) -> Any:
114106
"""Add a key to the VDOM or component on the current route, if it doesn't already have one."""
115-
element, _params = match
107+
element = match.element
116108
if hasattr(element, "render") and not element.key:
117109
element = cast(ComponentType, element)
118110
element.key = key
@@ -125,24 +117,12 @@ def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
125117
def _match_route(
126118
compiled_routes: Sequence[CompiledRoute],
127119
location: Location,
128-
select: Literal["first", "all"],
129-
) -> list[tuple[Any, dict[str, Any]]]:
130-
matches = []
131-
120+
) -> MatchedRoute | None:
132121
for resolver in compiled_routes:
133122
match = resolver.resolve(location.pathname)
134123
if match is not None:
135-
if select == "first":
136-
return [_add_route_key(match, resolver.key)]
124+
return _add_route_key(match, resolver.key)
137125

138-
# Matching multiple routes is disabled since `react-router` no longer supports multiple
139-
# matches via the `Route` component. However, it's kept here to support future changes
140-
# or third-party routers.
141-
# TODO: The `resolver.key` value has edge cases where it is not unique enough to use as
142-
# a key here. We can potentially fix this by throwing errors for duplicate identical routes.
143-
matches.append(_add_route_key(match, resolver.key)) # pragma: no cover
126+
_logger.debug("No matching route found for %s", location.pathname)
144127

145-
if not matches:
146-
_logger.debug("No matching route found for %s", location.pathname)
147-
148-
return matches
128+
return None

src/reactpy_router/types.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ def __call__(self, *routes: RouteType_contra) -> Component:
6767

6868

6969
class Resolver(Protocol[RouteType_contra]):
70-
"""Compile a route into a resolver that can be matched against a given path."""
70+
"""A class, that when instantiated, can match routes against a given path."""
7171

7272
def __call__(self, route: RouteType_contra) -> CompiledRoute:
7373
"""
74-
Compile a route into a resolver that can be matched against a given path.
74+
Compile a route into a resolver that can be match routes against a given path.
7575
7676
Args:
7777
route: The route to compile.
@@ -87,13 +87,13 @@ class CompiledRoute(Protocol):
8787
A protocol for a compiled route that can be matched against a path.
8888
8989
Attributes:
90-
key (Key): A property that uniquely identifies this resolver.
90+
key: A property that uniquely identifies this resolver.
9191
"""
9292

9393
@property
9494
def key(self) -> Key: ...
9595

96-
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
96+
def resolve(self, path: str) -> MatchedRoute | None:
9797
"""
9898
Return the path's associated element and path parameters or None.
9999
@@ -106,6 +106,22 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
106106
...
107107

108108

109+
@dataclass(frozen=True)
110+
class MatchedRoute:
111+
"""
112+
Represents a matched route.
113+
114+
Attributes:
115+
element (Any): The element to render.
116+
params (dict[str, Any]): The parameters extracted from the path.
117+
path (str): The path that was matched.
118+
"""
119+
120+
element: Any
121+
params: dict[str, Any]
122+
path: str
123+
124+
109125
class ConversionInfo(TypedDict):
110126
"""
111127
A TypedDict that holds information about a conversion type.

tests/test_resolver.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55

66
from reactpy_router import route
77
from reactpy_router.resolvers import StarletteResolver
8+
from reactpy_router.types import MatchedRoute
89

910

1011
def test_resolve_any():
1112
resolver = StarletteResolver(route("{404:any}", "Hello World"))
1213
assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$")
1314
assert resolver.converter_mapping == {"_numeric_404": str}
14-
assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"})
15+
assert resolver.resolve("/hello/world") == MatchedRoute(
16+
element="Hello World", params={"404": "/hello/world"}, path="/hello/world"
17+
)
1518

1619

1720
def test_parse_path():

0 commit comments

Comments
 (0)