Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single Page Application (SPA) Compatibility via reactpy-router #185

Merged
merged 22 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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