diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a36624..a5dc50df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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" %}`. diff --git a/docs/python/django-router.py b/docs/python/django-router.py new file mode 100644 index 00000000..714ca585 --- /dev/null +++ b/docs/python/django-router.py @@ -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//", html.div("Example 2")), + route("/router/integer//", html.div("Example 3")), + route("/router/path//", html.div("Example 4")), + route("/router/slug//", html.div("Example 5")), + route("/router/string//", html.div("Example 6")), + route("/router/uuid//", html.div("Example 7")), + route("/router/two_values///", html.div("Example 9")), + ) diff --git a/docs/python/use-location.py b/docs/python/use-location.py index 43ae6352..d7afcbac 100644 --- a/docs/python/use-location.py +++ b/docs/python/use-location.py @@ -6,4 +6,4 @@ def my_component(): location = use_location() - return html.div(str(location)) + return html.div(location.pathname + location.search) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 3529fe7b..3ba03266 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -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" @@ -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`. diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md new file mode 100644 index 00000000..7a280c6d --- /dev/null +++ b/docs/src/reference/router.md @@ -0,0 +1,41 @@ +## Overview + +

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

+ +!!! 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" + + **Parameters** + + | 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 | + + **Returns** + + | 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. diff --git a/mkdocs.yml b/mkdocs.yml index e7b350e1..2eff331d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 8eecf7bc..c6102c18 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -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 diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 6f79df77..6966a0f0 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -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"; diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 56a85aac..97b20efd 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -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}`, }, diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 54a0b604..b31276bc 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -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; +}; diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index a3f653ce..56e231e2 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -1,5 +1,5 @@ export function createReconnectingWebSocket(props: { - url: string; + url: URL; readyPromise: Promise; onOpen?: () => void; onMessage: (message: MessageEvent) => void; @@ -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 ); diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 3985fcf4..fb9ed61d 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -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, @@ -18,6 +18,7 @@ "types", "utils", "checks", + "router", ] # Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops. diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 755db506..0ebf2ea8 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -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() @@ -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() @@ -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() diff --git a/src/reactpy_django/router/__init__.py b/src/reactpy_django/router/__init__.py new file mode 100644 index 00000000..ea3e1d3b --- /dev/null +++ b/src/reactpy_django/router/__init__.py @@ -0,0 +1,3 @@ +from reactpy_django.router.components import django_router + +__all__ = ["django_router"] diff --git a/src/reactpy_django/router/components.py b/src/reactpy_django/router/components.py new file mode 100644 index 00000000..60aca8fd --- /dev/null +++ b/src/reactpy_django/router/components.py @@ -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\w+:)?(?P\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) diff --git a/src/reactpy_django/router/converters.py b/src/reactpy_django/router/converters.py new file mode 100644 index 00000000..3611f63e --- /dev/null +++ b/src/reactpy_django/router/converters.py @@ -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() +} diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 3d8f8d75..f1633c88 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -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 @@ -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): @@ -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() diff --git a/tests/test_app/router/__init__.py b/tests/test_app/router/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py new file mode 100644 index 00000000..8f020990 --- /dev/null +++ b/tests/test_app/router/components.py @@ -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//", display_params("Path 2", route_info)), + route("/router/integer//", display_params("Path 3", route_info)), + route("/router/path//", display_params("Path 4", route_info)), + route("/router/slug//", display_params("Path 5", route_info)), + route("/router/string//", display_params("Path 6", route_info)), + route("/router/uuid//", display_params("Path 7", route_info)), + route("/router/", None, route("abc/", display_params("Path 8", route_info))), + route( + "/router/two///", + display_params("Path 9", route_info), + ), + ) diff --git a/tests/test_app/router/urls.py b/tests/test_app/router/urls.py new file mode 100644 index 00000000..b497b951 --- /dev/null +++ b/tests/test_app/router/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from test_app.router.views import router + +urlpatterns = [ + re_path(r"^router/(?P.*)/?$", router), +] diff --git a/tests/test_app/router/views.py b/tests/test_app/router/views.py new file mode 100644 index 00000000..6189f4a4 --- /dev/null +++ b/tests/test_app/router/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def router(request, path=None): + return render(request, "router.html", {}) diff --git a/tests/test_app/templates/router.html b/tests/test_app/templates/router.html new file mode 100644 index 00000000..ee15fb64 --- /dev/null +++ b/tests/test_app/templates/router.html @@ -0,0 +1,20 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Router Test Page

+
+ {% component "test_app.router.components.main" %} +
+ + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index af936955..ca35cf50 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -553,3 +553,50 @@ def test_use_user_data_with_default(self): "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}", user_data_div.text_content(), ) + + def test_url_router(self): + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/router/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/any/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/any/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/integer/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/path/abc/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/slug/abc-123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/string/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + + new_page.goto( + f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" + ) + path = new_page.wait_for_selector("#router-path") + self.assertIn( + "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", + path.get_attribute("data-path"), + ) + + new_page.goto(f"{self.live_server_url}/router/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/abc/", path.get_attribute("data-path")) + + new_page.goto(f"{self.live_server_url}/router/two/123/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + + finally: + new_page.close() diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 406bdc0a..50cc5999 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -29,6 +29,7 @@ path("errors/", views.errors_template), path("", include("test_app.prerender.urls")), path("", include("test_app.performance.urls")), + path("", include("test_app.router.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), ]