Skip to content

Commit

Permalink
Single Page Application (SPA) compatibility via reactpy-router (#185)
Browse files Browse the repository at this point in the history
Port reactpy-router into a Django equivalent (using Django's URL matching schema)
  • Loading branch information
Archmonger authored Jan 10, 2024
1 parent 6fb7ba2 commit 023cb15
Show file tree
Hide file tree
Showing 24 changed files with 293 additions and 37 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Using the following categories, list your changes in this order:

### Added

- Built-in Single Page Application (SPA) support!
- `reactpy_django.router.django_router` can be used to render your Django application as a SPA.
- SEO compatible rendering!
- `settings.py:REACTPY_PRERENDER` can be set to `True` to make components pre-render by default.
- Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}`.
Expand Down
17 changes: 17 additions & 0 deletions docs/python/django-router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from reactpy import component, html
from reactpy_django.router import django_router
from reactpy_router import route


@component
def my_component():
return django_router(
route("/router/", html.div("Example 1")),
route("/router/any/<value>/", html.div("Example 2")),
route("/router/integer/<int:value>/", html.div("Example 3")),
route("/router/path/<path:value>/", html.div("Example 4")),
route("/router/slug/<slug:value>/", html.div("Example 5")),
route("/router/string/<str:value>/", html.div("Example 6")),
route("/router/uuid/<uuid:value>/", html.div("Example 7")),
route("/router/two_values/<int:value>/<str:value2>/", html.div("Example 9")),
)
2 changes: 1 addition & 1 deletion docs/python/use-location.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
def my_component():
location = use_location()

return html.div(str(location))
return html.div(location.pathname + location.search)
12 changes: 1 addition & 11 deletions docs/src/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,7 @@ Shortcut that returns the WebSocket or HTTP connection's [scope](https://channel

### `#!python use_location()`

Shortcut that returns the WebSocket or HTTP connection's URL `#!python path`.

You can expect this hook to provide strings such as `/reactpy/my_path`.
Shortcut that returns the browser's current `#!python Location`.

=== "components.py"

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

??? info "This hook's behavior will be changed in a future update"

This hook will be updated to return the browser's currently active HTTP path. This change will come in alongside ReactPy URL routing support.

Check out [reactive-python/reactpy-django#147](https://github.com/reactive-python/reactpy-django/issues/147) for more information.

---

### `#!python use_origin()`

Shortcut that returns the WebSocket or HTTP connection's `#!python origin`.
Expand Down
41 changes: 41 additions & 0 deletions docs/src/reference/router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Overview

<p class="intro" markdown>

A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions.

</p>

!!! abstract "Note"

Looking for more details on URL routing?

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/).

---

## `#!python django_router(*routes)`

=== "components.py"

```python
{% include "../../python/django-router.py" %}
```

??? example "See Interface"

<font size="4">**Parameters**</font>

| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `#!python *routes` | `#!python Route` | An object from `reactpy-router` containing a `#!python path`, `#!python element`, and child `#!python *routes`. | N/A |

<font size="4">**Returns**</font>

| Type | Description |
| --- | --- |
| `#!python VdomDict | None` | The matched component/path after it has been fully rendered. |

??? question "How is this different from `#!python reactpy_router.simple.router`?"

This component utilizes `reactpy-router` under the hood, but provides a more Django-like URL routing syntax.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ nav:
- Reference:
- Components: reference/components.md
- Hooks: reference/hooks.md
- URL Router: reference/router.md
- Decorators: reference/decorators.md
- Utilities: reference/utils.md
- Template Tag: reference/template-tag.md
Expand Down
1 change: 1 addition & 0 deletions requirements/pkg-deps.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
channels >=4.0.0
django >=4.2.0
reactpy >=1.0.2, <1.1.0
reactpy-router >=0.1.1, <1.0.0
aiofile >=3.0
dill >=0.3.5
orjson >=3.6.0
Expand Down
6 changes: 5 additions & 1 deletion src/js/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client";
import {
BaseReactPyClient,
ReactPyClient,
ReactPyModule,
} from "@reactpy/client";
import { createReconnectingWebSocket } from "./utils";
import { ReactPyDjangoClientProps, ReactPyUrls } from "./types";

Expand Down
9 changes: 8 additions & 1 deletion src/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,17 @@ export function mountComponent(
}
}

// Embed the initial HTTP path into the WebSocket URL
let componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`);
componentUrl.searchParams.append("http_pathname", window.location.pathname);
if (window.location.search) {
componentUrl.searchParams.append("http_search", window.location.search);
}

// Configure a new ReactPy client
const client = new ReactPyDjangoClient({
urls: {
componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`,
componentUrl: componentUrl,
query: document.location.search,
jsModules: `${httpOrigin}/${jsModulesPath}`,
},
Expand Down
24 changes: 12 additions & 12 deletions src/js/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
export type ReconnectOptions = {
startInterval: number;
maxInterval: number;
maxRetries: number;
backoffMultiplier: number;
}
startInterval: number;
maxInterval: number;
maxRetries: number;
backoffMultiplier: number;
};

export type ReactPyUrls = {
componentUrl: string;
query: string;
jsModules: string;
}
componentUrl: URL;
query: string;
jsModules: string;
};

export type ReactPyDjangoClientProps = {
urls: ReactPyUrls;
reconnectOptions: ReconnectOptions;
}
urls: ReactPyUrls;
reconnectOptions: ReconnectOptions;
};
7 changes: 3 additions & 4 deletions src/js/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export function createReconnectingWebSocket(props: {
url: string;
url: URL;
readyPromise: Promise<void>;
onOpen?: () => void;
onMessage: (message: MessageEvent<any>) => void;
Expand Down Expand Up @@ -68,9 +68,8 @@ export function nextInterval(
maxInterval: number
): number {
return Math.min(
currentInterval *
// increase interval by backoff multiplier
backoffMultiplier,
// increase interval by backoff multiplier
currentInterval * backoffMultiplier,
// don't exceed max interval
maxInterval
);
Expand Down
3 changes: 2 additions & 1 deletion src/reactpy_django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import nest_asyncio

from reactpy_django import checks, components, decorators, hooks, types, utils
from reactpy_django import checks, components, decorators, hooks, router, types, utils
from reactpy_django.websocket.paths import (
REACTPY_WEBSOCKET_PATH,
REACTPY_WEBSOCKET_ROUTE,
Expand All @@ -18,6 +18,7 @@
"types",
"utils",
"checks",
"router",
]

# Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops.
Expand Down
3 changes: 3 additions & 0 deletions src/reactpy_django/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
] = DefaultDict(set)


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


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


# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.*
def use_connection() -> ConnectionType:
"""Get the current `Connection` object"""
return _use_connection()
Expand Down
3 changes: 3 additions & 0 deletions src/reactpy_django/router/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from reactpy_django.router.components import django_router

__all__ = ["django_router"]
56 changes: 56 additions & 0 deletions src/reactpy_django/router/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import re
from typing import Any

from reactpy_router.core import create_router
from reactpy_router.simple import ConverterMapping
from reactpy_router.types import Route

from reactpy_django.router.converters import CONVERTERS

PARAM_PATTERN = re.compile(r"<(?P<type>\w+:)?(?P<name>\w+)>")


# TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own
class DjangoResolver:
"""A simple route resolver that uses regex to match paths"""

def __init__(self, route: Route) -> None:
self.element = route.element
self.pattern, self.converters = parse_path(route.path)
self.key = self.pattern.pattern

def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
match = self.pattern.match(path)
if match:
return (
self.element,
{k: self.converters[k](v) for k, v in match.groupdict().items()},
)
return None


# TODO: Make reactpy_router's parse_path generic enough to where we don't have to define our own
def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
pattern = "^"
last_match_end = 0
converters: ConverterMapping = {}
for match in PARAM_PATTERN.finditer(path):
param_name = match.group("name")
param_type = (match.group("type") or "str").strip(":")
try:
param_conv = CONVERTERS[param_type]
except KeyError as e:
raise ValueError(
f"Unknown conversion type {param_type!r} in {path!r}"
) from e
pattern += re.escape(path[last_match_end : match.start()])
pattern += f"(?P<{param_name}>{param_conv['regex']})"
converters[param_name] = param_conv["func"]
last_match_end = match.end()
pattern += f"{re.escape(path[last_match_end:])}$"
return re.compile(pattern), converters


django_router = create_router(DjangoResolver)
7 changes: 7 additions & 0 deletions src/reactpy_django/router/converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls.converters import get_converters
from reactpy_router.simple import ConversionInfo

CONVERTERS: dict[str, ConversionInfo] = {
name: {"regex": converter.regex, "func": converter.to_python}
for name, converter in get_converters().items()
}
14 changes: 8 additions & 6 deletions src/reactpy_django/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from datetime import timedelta
from threading import Thread
from typing import TYPE_CHECKING, Any, MutableMapping, Sequence
from urllib.parse import parse_qs

import dill as pickle
import orjson
Expand Down Expand Up @@ -38,7 +39,9 @@ def start_backhaul_loop():
backhaul_loop.run_forever()


backhaul_thread = Thread(target=start_backhaul_loop, daemon=True)
backhaul_thread = Thread(
target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul"
)


class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer):
Expand Down Expand Up @@ -146,14 +149,13 @@ async def run_dispatcher(self):
scope = self.scope
self.dotted_path = dotted_path = scope["url_route"]["kwargs"]["dotted_path"]
uuid = scope["url_route"]["kwargs"].get("uuid")
search = scope["query_string"].decode()
query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True)
http_pathname = query_string.get("http_pathname", [""])[0]
http_search = query_string.get("http_search", [""])[0]
self.recv_queue: asyncio.Queue = asyncio.Queue()
connection = Connection( # For `use_connection`
scope=scope,
location=Location(
pathname=scope["path"],
search=f"?{search}" if (search and (search != "undefined")) else "",
),
location=Location(pathname=http_pathname, search=http_search),
carrier=self,
)
now = timezone.now()
Expand Down
Empty file.
42 changes: 42 additions & 0 deletions tests/test_app/router/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from reactpy import component, html, use_location
from reactpy_django.router import django_router
from reactpy_router import route, use_params, use_query


@component
def display_params(*args):
params = use_params()
return html._(
html.div(f"Params: {params}"),
*args,
)


@component
def main():
location = use_location()
query = use_query()

route_info = html._(
html.div(
{"id": "router-path", "data-path": location.pathname},
f"Path Name: {location.pathname}",
),
html.div(f"Query String: {location.search}"),
html.div(f"Query: {query}"),
)

return django_router(
route("/router/", html.div("Path 1", route_info)),
route("/router/any/<value>/", display_params("Path 2", route_info)),
route("/router/integer/<int:value>/", display_params("Path 3", route_info)),
route("/router/path/<path:value>/", display_params("Path 4", route_info)),
route("/router/slug/<slug:value>/", display_params("Path 5", route_info)),
route("/router/string/<str:value>/", display_params("Path 6", route_info)),
route("/router/uuid/<uuid:value>/", display_params("Path 7", route_info)),
route("/router/", None, route("abc/", display_params("Path 8", route_info))),
route(
"/router/two/<int:value>/<str:value2>/",
display_params("Path 9", route_info),
),
)
7 changes: 7 additions & 0 deletions tests/test_app/router/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import re_path

from test_app.router.views import router

urlpatterns = [
re_path(r"^router/(?P<path>.*)/?$", router),
]
5 changes: 5 additions & 0 deletions tests/test_app/router/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.shortcuts import render


def router(request, path=None):
return render(request, "router.html", {})
Loading

0 comments on commit 023cb15

Please sign in to comment.