Skip to content

Commit 023cb15

Browse files
authored
Single Page Application (SPA) compatibility via reactpy-router (#185)
Port reactpy-router into a Django equivalent (using Django's URL matching schema)
1 parent 6fb7ba2 commit 023cb15

24 files changed

+293
-37
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Using the following categories, list your changes in this order:
3636

3737
### Added
3838

39+
- Built-in Single Page Application (SPA) support!
40+
- `reactpy_django.router.django_router` can be used to render your Django application as a SPA.
3941
- SEO compatible rendering!
4042
- `settings.py:REACTPY_PRERENDER` can be set to `True` to make components pre-render by default.
4143
- Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}`.

docs/python/django-router.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from reactpy import component, html
2+
from reactpy_django.router import django_router
3+
from reactpy_router import route
4+
5+
6+
@component
7+
def my_component():
8+
return django_router(
9+
route("/router/", html.div("Example 1")),
10+
route("/router/any/<value>/", html.div("Example 2")),
11+
route("/router/integer/<int:value>/", html.div("Example 3")),
12+
route("/router/path/<path:value>/", html.div("Example 4")),
13+
route("/router/slug/<slug:value>/", html.div("Example 5")),
14+
route("/router/string/<str:value>/", html.div("Example 6")),
15+
route("/router/uuid/<uuid:value>/", html.div("Example 7")),
16+
route("/router/two_values/<int:value>/<str:value2>/", html.div("Example 9")),
17+
)

docs/python/use-location.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
def my_component():
77
location = use_location()
88

9-
return html.div(str(location))
9+
return html.div(location.pathname + location.search)

docs/src/reference/hooks.md

+1-11
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,7 @@ Shortcut that returns the WebSocket or HTTP connection's [scope](https://channel
366366

367367
### `#!python use_location()`
368368

369-
Shortcut that returns the WebSocket or HTTP connection's URL `#!python path`.
370-
371-
You can expect this hook to provide strings such as `/reactpy/my_path`.
369+
Shortcut that returns the browser's current `#!python Location`.
372370

373371
=== "components.py"
374372

@@ -388,14 +386,6 @@ You can expect this hook to provide strings such as `/reactpy/my_path`.
388386
| --- | --- |
389387
| `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. |
390388

391-
??? info "This hook's behavior will be changed in a future update"
392-
393-
This hook will be updated to return the browser's currently active HTTP path. This change will come in alongside ReactPy URL routing support.
394-
395-
Check out [reactive-python/reactpy-django#147](https://github.com/reactive-python/reactpy-django/issues/147) for more information.
396-
397-
---
398-
399389
### `#!python use_origin()`
400390

401391
Shortcut that returns the WebSocket or HTTP connection's `#!python origin`.

docs/src/reference/router.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
## Overview
2+
3+
<p class="intro" markdown>
4+
5+
A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions.
6+
7+
</p>
8+
9+
!!! abstract "Note"
10+
11+
Looking for more details on URL routing?
12+
13+
This package only contains Django specific URL routing features. Standard features can be found within [`reactive-python/reactpy-router`](https://reactive-python.github.io/reactpy-router/).
14+
15+
---
16+
17+
## `#!python django_router(*routes)`
18+
19+
=== "components.py"
20+
21+
```python
22+
{% include "../../python/django-router.py" %}
23+
```
24+
25+
??? example "See Interface"
26+
27+
<font size="4">**Parameters**</font>
28+
29+
| Name | Type | Description | Default |
30+
| --- | --- | --- | --- |
31+
| `#!python *routes` | `#!python Route` | An object from `reactpy-router` containing a `#!python path`, `#!python element`, and child `#!python *routes`. | N/A |
32+
33+
<font size="4">**Returns**</font>
34+
35+
| Type | Description |
36+
| --- | --- |
37+
| `#!python VdomDict | None` | The matched component/path after it has been fully rendered. |
38+
39+
??? question "How is this different from `#!python reactpy_router.simple.router`?"
40+
41+
This component utilizes `reactpy-router` under the hood, but provides a more Django-like URL routing syntax.

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ nav:
77
- Reference:
88
- Components: reference/components.md
99
- Hooks: reference/hooks.md
10+
- URL Router: reference/router.md
1011
- Decorators: reference/decorators.md
1112
- Utilities: reference/utils.md
1213
- Template Tag: reference/template-tag.md

requirements/pkg-deps.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
channels >=4.0.0
22
django >=4.2.0
33
reactpy >=1.0.2, <1.1.0
4+
reactpy-router >=0.1.1, <1.0.0
45
aiofile >=3.0
56
dill >=0.3.5
67
orjson >=3.6.0

src/js/src/client.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client";
1+
import {
2+
BaseReactPyClient,
3+
ReactPyClient,
4+
ReactPyModule,
5+
} from "@reactpy/client";
26
import { createReconnectingWebSocket } from "./utils";
37
import { ReactPyDjangoClientProps, ReactPyUrls } from "./types";
48

src/js/src/index.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@ export function mountComponent(
3939
}
4040
}
4141

42+
// Embed the initial HTTP path into the WebSocket URL
43+
let componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`);
44+
componentUrl.searchParams.append("http_pathname", window.location.pathname);
45+
if (window.location.search) {
46+
componentUrl.searchParams.append("http_search", window.location.search);
47+
}
48+
4249
// Configure a new ReactPy client
4350
const client = new ReactPyDjangoClient({
4451
urls: {
45-
componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`,
52+
componentUrl: componentUrl,
4653
query: document.location.search,
4754
jsModules: `${httpOrigin}/${jsModulesPath}`,
4855
},

src/js/src/types.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
export type ReconnectOptions = {
2-
startInterval: number;
3-
maxInterval: number;
4-
maxRetries: number;
5-
backoffMultiplier: number;
6-
}
2+
startInterval: number;
3+
maxInterval: number;
4+
maxRetries: number;
5+
backoffMultiplier: number;
6+
};
77

88
export type ReactPyUrls = {
9-
componentUrl: string;
10-
query: string;
11-
jsModules: string;
12-
}
9+
componentUrl: URL;
10+
query: string;
11+
jsModules: string;
12+
};
1313

1414
export type ReactPyDjangoClientProps = {
15-
urls: ReactPyUrls;
16-
reconnectOptions: ReconnectOptions;
17-
}
15+
urls: ReactPyUrls;
16+
reconnectOptions: ReconnectOptions;
17+
};

src/js/src/utils.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export function createReconnectingWebSocket(props: {
2-
url: string;
2+
url: URL;
33
readyPromise: Promise<void>;
44
onOpen?: () => void;
55
onMessage: (message: MessageEvent<any>) => void;
@@ -68,9 +68,8 @@ export function nextInterval(
6868
maxInterval: number
6969
): number {
7070
return Math.min(
71-
currentInterval *
72-
// increase interval by backoff multiplier
73-
backoffMultiplier,
71+
// increase interval by backoff multiplier
72+
currentInterval * backoffMultiplier,
7473
// don't exceed max interval
7574
maxInterval
7675
);

src/reactpy_django/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import nest_asyncio
44

5-
from reactpy_django import checks, components, decorators, hooks, types, utils
5+
from reactpy_django import checks, components, decorators, hooks, router, types, utils
66
from reactpy_django.websocket.paths import (
77
REACTPY_WEBSOCKET_PATH,
88
REACTPY_WEBSOCKET_ROUTE,
@@ -18,6 +18,7 @@
1818
"types",
1919
"utils",
2020
"checks",
21+
"router",
2122
]
2223

2324
# Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops.

src/reactpy_django/hooks.py

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
] = DefaultDict(set)
4646

4747

48+
# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.*
4849
def use_location() -> Location:
4950
"""Get the current route as a `Location` object"""
5051
return _use_location()
@@ -78,6 +79,7 @@ def use_origin() -> str | None:
7879
return None
7980

8081

82+
# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.*
8183
def use_scope() -> dict[str, Any]:
8284
"""Get the current ASGI scope dictionary"""
8385
scope = _use_scope()
@@ -88,6 +90,7 @@ def use_scope() -> dict[str, Any]:
8890
raise TypeError(f"Expected scope to be a dict, got {type(scope)}")
8991

9092

93+
# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.*
9194
def use_connection() -> ConnectionType:
9295
"""Get the current `Connection` object"""
9396
return _use_connection()

src/reactpy_django/router/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from reactpy_django.router.components import django_router
2+
3+
__all__ = ["django_router"]
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import Any
5+
6+
from reactpy_router.core import create_router
7+
from reactpy_router.simple import ConverterMapping
8+
from reactpy_router.types import Route
9+
10+
from reactpy_django.router.converters import CONVERTERS
11+
12+
PARAM_PATTERN = re.compile(r"<(?P<type>\w+:)?(?P<name>\w+)>")
13+
14+
15+
# TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own
16+
class DjangoResolver:
17+
"""A simple route resolver that uses regex to match paths"""
18+
19+
def __init__(self, route: Route) -> None:
20+
self.element = route.element
21+
self.pattern, self.converters = parse_path(route.path)
22+
self.key = self.pattern.pattern
23+
24+
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
25+
match = self.pattern.match(path)
26+
if match:
27+
return (
28+
self.element,
29+
{k: self.converters[k](v) for k, v in match.groupdict().items()},
30+
)
31+
return None
32+
33+
34+
# TODO: Make reactpy_router's parse_path generic enough to where we don't have to define our own
35+
def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
36+
pattern = "^"
37+
last_match_end = 0
38+
converters: ConverterMapping = {}
39+
for match in PARAM_PATTERN.finditer(path):
40+
param_name = match.group("name")
41+
param_type = (match.group("type") or "str").strip(":")
42+
try:
43+
param_conv = CONVERTERS[param_type]
44+
except KeyError as e:
45+
raise ValueError(
46+
f"Unknown conversion type {param_type!r} in {path!r}"
47+
) from e
48+
pattern += re.escape(path[last_match_end : match.start()])
49+
pattern += f"(?P<{param_name}>{param_conv['regex']})"
50+
converters[param_name] = param_conv["func"]
51+
last_match_end = match.end()
52+
pattern += f"{re.escape(path[last_match_end:])}$"
53+
return re.compile(pattern), converters
54+
55+
56+
django_router = create_router(DjangoResolver)
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls.converters import get_converters
2+
from reactpy_router.simple import ConversionInfo
3+
4+
CONVERTERS: dict[str, ConversionInfo] = {
5+
name: {"regex": converter.regex, "func": converter.to_python}
6+
for name, converter in get_converters().items()
7+
}

src/reactpy_django/websocket/consumer.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from datetime import timedelta
1111
from threading import Thread
1212
from typing import TYPE_CHECKING, Any, MutableMapping, Sequence
13+
from urllib.parse import parse_qs
1314

1415
import dill as pickle
1516
import orjson
@@ -38,7 +39,9 @@ def start_backhaul_loop():
3839
backhaul_loop.run_forever()
3940

4041

41-
backhaul_thread = Thread(target=start_backhaul_loop, daemon=True)
42+
backhaul_thread = Thread(
43+
target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul"
44+
)
4245

4346

4447
class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer):
@@ -146,14 +149,13 @@ async def run_dispatcher(self):
146149
scope = self.scope
147150
self.dotted_path = dotted_path = scope["url_route"]["kwargs"]["dotted_path"]
148151
uuid = scope["url_route"]["kwargs"].get("uuid")
149-
search = scope["query_string"].decode()
152+
query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True)
153+
http_pathname = query_string.get("http_pathname", [""])[0]
154+
http_search = query_string.get("http_search", [""])[0]
150155
self.recv_queue: asyncio.Queue = asyncio.Queue()
151156
connection = Connection( # For `use_connection`
152157
scope=scope,
153-
location=Location(
154-
pathname=scope["path"],
155-
search=f"?{search}" if (search and (search != "undefined")) else "",
156-
),
158+
location=Location(pathname=http_pathname, search=http_search),
157159
carrier=self,
158160
)
159161
now = timezone.now()

tests/test_app/router/__init__.py

Whitespace-only changes.

tests/test_app/router/components.py

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from reactpy import component, html, use_location
2+
from reactpy_django.router import django_router
3+
from reactpy_router import route, use_params, use_query
4+
5+
6+
@component
7+
def display_params(*args):
8+
params = use_params()
9+
return html._(
10+
html.div(f"Params: {params}"),
11+
*args,
12+
)
13+
14+
15+
@component
16+
def main():
17+
location = use_location()
18+
query = use_query()
19+
20+
route_info = html._(
21+
html.div(
22+
{"id": "router-path", "data-path": location.pathname},
23+
f"Path Name: {location.pathname}",
24+
),
25+
html.div(f"Query String: {location.search}"),
26+
html.div(f"Query: {query}"),
27+
)
28+
29+
return django_router(
30+
route("/router/", html.div("Path 1", route_info)),
31+
route("/router/any/<value>/", display_params("Path 2", route_info)),
32+
route("/router/integer/<int:value>/", display_params("Path 3", route_info)),
33+
route("/router/path/<path:value>/", display_params("Path 4", route_info)),
34+
route("/router/slug/<slug:value>/", display_params("Path 5", route_info)),
35+
route("/router/string/<str:value>/", display_params("Path 6", route_info)),
36+
route("/router/uuid/<uuid:value>/", display_params("Path 7", route_info)),
37+
route("/router/", None, route("abc/", display_params("Path 8", route_info))),
38+
route(
39+
"/router/two/<int:value>/<str:value2>/",
40+
display_params("Path 9", route_info),
41+
),
42+
)

tests/test_app/router/urls.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import re_path
2+
3+
from test_app.router.views import router
4+
5+
urlpatterns = [
6+
re_path(r"^router/(?P<path>.*)/?$", router),
7+
]

tests/test_app/router/views.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.shortcuts import render
2+
3+
4+
def router(request, path=None):
5+
return render(request, "router.html", {})

0 commit comments

Comments
 (0)