diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6a63334..5aa0b819 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,12 +37,17 @@ Using the following categories, list your changes in this order:
### Added
- Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook.
+- Access to the root component's `id` via the `reactpy_django.hooks.use_root_id` hook.
- More robust control over ReactPy clean up tasks!
- `settings.py:REACTPY_CLEAN_INTERVAL` to control how often ReactPy automatically performs cleaning tasks.
- `settings.py:REACTPY_CLEAN_SESSIONS` to control whether ReactPy automatically cleans up expired sessions.
- `settings.py:REACTPY_CLEAN_USER_DATA` to control whether ReactPy automatically cleans up orphaned user data.
- `python manage.py clean_reactpy` command to manually perform ReactPy clean up tasks.
+### Changed
+
+- Simplified code for cascading deletion of UserData.
+
## [3.7.0] - 2024-01-30
### Added
diff --git a/docs/examples/python/use-root-id.py b/docs/examples/python/use-root-id.py
new file mode 100644
index 00000000..f2088cc4
--- /dev/null
+++ b/docs/examples/python/use-root-id.py
@@ -0,0 +1,9 @@
+from reactpy import component, html
+from reactpy_django.hooks import use_root_id
+
+
+@component
+def my_component():
+ root_id = use_root_id()
+
+ return html.div(f"Root ID: {root_id}")
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
index 5ceb2fe6..d715874e 100644
--- a/docs/src/reference/hooks.md
+++ b/docs/src/reference/hooks.md
@@ -492,6 +492,8 @@ Shortcut that returns the browser's current `#!python Location`.
| --- | --- |
| `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. |
+---
+
### Use Origin
Shortcut that returns the WebSocket or HTTP connection's `#!python origin`.
@@ -518,6 +520,34 @@ You can expect this hook to provide strings such as `http://example.com`.
---
+### Use Root ID
+
+Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection.
+
+The root ID is currently a randomly generated `#!python uuid4` (unique across all root component).
+
+This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance, and/or retain a backlog of messages in case that component is disconnected via `#!python use_channel_layer( ... , group_discard=False)`.
+
+=== "components.py"
+
+ ```python
+ {% include "../../examples/python/use-root-id.py" %}
+ ```
+
+??? example "See Interface"
+
+ **Parameters**
+
+ `#!python None`
+
+ **Returns**
+
+ | Type | Description |
+ | --- | --- |
+ | `#!python str` | A string containing the root component's `#!python id`. |
+
+---
+
### Use User
Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 448d43b9..d2574751 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -436,6 +436,14 @@ async def message_sender(message: dict):
return message_sender
+def use_root_id() -> str:
+ """Get the root element's ID. This value is guaranteed to be unique. Current versions of \
+ ReactPy-Django return a `uuid4` string."""
+ scope = use_scope()
+
+ return scope["reactpy"]["id"]
+
+
def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs):
return options, query, args, kwargs
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 7c413b76..180b0b31 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -1,5 +1,3 @@
-import contextlib
-
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.signals import pre_delete
@@ -7,9 +5,7 @@
class ComponentSession(models.Model):
- """A model for storing component sessions.
- All queries must be routed through `reactpy_django.config.REACTPY_DATABASE`.
- """
+ """A model for storing component sessions."""
uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore
params = models.BinaryField(editable=False) # type: ignore
@@ -36,16 +32,15 @@ class UserDataModel(models.Model):
"""A model for storing `user_state` data."""
# We can't store User as a ForeignKey/OneToOneField because it may not be in the same database
- # and Django does not allow cross-database relations. Also, we can't know the type of the UserModel PK,
- # so we store it as a string.
+ # and Django does not allow cross-database relations. Also, since we can't know the type of the UserModel PK,
+ # we store it as a string to normalize.
user_pk = models.CharField(max_length=255, unique=True) # type: ignore
data = models.BinaryField(null=True, blank=True) # type: ignore
@receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data")
def delete_user_data(sender, instance, **kwargs):
- """Delete `UserDataModel` when the `User` is deleted."""
+ """Delete ReactPy's `UserDataModel` when a Django `User` is deleted."""
pk = getattr(instance, instance._meta.pk.name)
- with contextlib.suppress(Exception):
- UserDataModel.objects.get(user_pk=pk).delete()
+ UserDataModel.objects.filter(user_pk=pk).delete()
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 8c175bc2..396c850d 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -77,9 +77,9 @@ def component(
or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "")
).strip("/")
is_local = not host or host.startswith(perceived_host)
- uuid = uuid4().hex
+ uuid = str(uuid4())
class_ = kwargs.pop("class", "")
- component_has_args = args or kwargs
+ has_args = bool(args or kwargs)
user_component: ComponentConstructor | None = None
_prerender_html = ""
_offline_html = ""
@@ -108,7 +108,7 @@ def component(
return failure_context(dotted_path, e)
# Store args & kwargs in the database (fetched by our websocket later)
- if component_has_args:
+ if has_args:
try:
save_component_params(args, kwargs, uuid)
except Exception as e:
@@ -135,7 +135,9 @@ def component(
)
_logger.error(msg)
return failure_context(dotted_path, ComponentCarrierError(msg))
- _prerender_html = prerender_component(user_component, args, kwargs, request)
+ _prerender_html = prerender_component(
+ user_component, args, kwargs, uuid, request
+ )
# Fetch the offline component's HTML, if requested
if offline:
@@ -151,7 +153,7 @@ def component(
)
_logger.error(msg)
return failure_context(dotted_path, ComponentCarrierError(msg))
- _offline_html = prerender_component(offline_component, [], {}, request)
+ _offline_html = prerender_component(offline_component, [], {}, uuid, request)
# Return the template rendering context
return {
@@ -159,9 +161,7 @@ def component(
"reactpy_uuid": uuid,
"reactpy_host": host or perceived_host,
"reactpy_url_prefix": config.REACTPY_URL_PREFIX,
- "reactpy_component_path": f"{dotted_path}/{uuid}/"
- if component_has_args
- else f"{dotted_path}/",
+ "reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
"reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
"reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL,
"reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
@@ -199,14 +199,17 @@ def validate_host(host: str):
def prerender_component(
- user_component: ComponentConstructor, args, kwargs, request: HttpRequest
+ user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest
):
search = request.GET.urlencode()
+ scope = getattr(request, "scope", {})
+ scope["reactpy"] = {"id": str(uuid)}
+
with SyncLayout(
ConnectionContext(
user_component(*args, **kwargs),
value=Connection(
- scope=getattr(request, "scope", {}),
+ scope=scope,
location=Location(
pathname=request.path, search=f"?{search}" if search else ""
),
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 195528ba..0d4c62d1 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -30,16 +30,16 @@
from django.contrib.auth.models import AbstractUser
_logger = logging.getLogger(__name__)
-backhaul_loop = asyncio.new_event_loop()
+BACKHAUL_LOOP = asyncio.new_event_loop()
def start_backhaul_loop():
"""Starts the asyncio event loop that will perform component rendering tasks."""
- asyncio.set_event_loop(backhaul_loop)
- backhaul_loop.run_forever()
+ asyncio.set_event_loop(BACKHAUL_LOOP)
+ BACKHAUL_LOOP.run_forever()
-backhaul_thread = Thread(
+BACKHAUL_THREAD = Thread(
target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul"
)
@@ -83,13 +83,13 @@ async def connect(self) -> None:
self.threaded = REACTPY_BACKHAUL_THREAD
self.component_session: models.ComponentSession | None = None
if self.threaded:
- if not backhaul_thread.is_alive():
+ if not BACKHAUL_THREAD.is_alive():
await asyncio.to_thread(
_logger.debug, "Starting ReactPy backhaul thread."
)
- backhaul_thread.start()
+ BACKHAUL_THREAD.start()
self.dispatcher = asyncio.run_coroutine_threadsafe(
- self.run_dispatcher(), backhaul_loop
+ self.run_dispatcher(), BACKHAUL_LOOP
)
else:
self.dispatcher = asyncio.create_task(self.run_dispatcher())
@@ -127,7 +127,7 @@ async def receive_json(self, content: Any, **_) -> None:
"""Receive a message from the browser. Typically, messages are event signals."""
if self.threaded:
asyncio.run_coroutine_threadsafe(
- self.recv_queue.put(content), backhaul_loop
+ self.recv_queue.put(content), BACKHAUL_LOOP
)
else:
await self.recv_queue.put(content)
@@ -151,6 +151,8 @@ 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")
+ has_args = scope["url_route"]["kwargs"].get("has_args")
+ scope["reactpy"] = {"id": str(uuid)}
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]
@@ -166,7 +168,7 @@ async def run_dispatcher(self):
# Verify the component has already been registered
try:
- component_constructor = REACTPY_REGISTERED_COMPONENTS[dotted_path]
+ root_component_constructor = REACTPY_REGISTERED_COMPONENTS[dotted_path]
except KeyError:
await asyncio.to_thread(
_logger.warning,
@@ -174,10 +176,9 @@ async def run_dispatcher(self):
)
return
- # Fetch the component's args/kwargs from the database, if needed
+ # Construct the component. This may require fetching the component's args/kwargs from the database.
try:
- if uuid:
- # Get the component session from the DB
+ if has_args:
self.component_session = await models.ComponentSession.objects.aget(
uuid=uuid,
last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE),
@@ -187,7 +188,7 @@ async def run_dispatcher(self):
component_session_kwargs = params.kwargs
# Generate the initial component instance
- component_instance = component_constructor(
+ root_component = root_component_constructor(
*component_session_args, **component_session_kwargs
)
except models.ComponentSession.DoesNotExist:
@@ -195,14 +196,14 @@ async def run_dispatcher(self):
_logger.warning,
f"Component session for '{dotted_path}:{uuid}' not found. The "
"session may have already expired beyond REACTPY_SESSION_MAX_AGE. "
- "If you are using a custom host, you may have forgotten to provide "
+ "If you are using a custom `host`, you may have forgotten to provide "
"args/kwargs.",
)
return
except Exception:
await asyncio.to_thread(
_logger.error,
- f"Failed to construct component {component_constructor} "
+ f"Failed to construct component {root_component_constructor} "
f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!\n"
f"{traceback.format_exc()}",
)
@@ -211,7 +212,7 @@ async def run_dispatcher(self):
# Start the ReactPy component rendering loop
with contextlib.suppress(Exception):
await serve_layout(
- Layout(ConnectionContext(component_instance, value=connection)),
+ Layout(ConnectionContext(root_component, value=connection)),
self.send_json,
self.recv_queue.get,
)
diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py
index fa185565..17a9c48e 100644
--- a/src/reactpy_django/websocket/paths.py
+++ b/src/reactpy_django/websocket/paths.py
@@ -1,4 +1,3 @@
-from channels.routing import URLRouter # noqa: E402
from django.urls import path
from reactpy_django.config import REACTPY_URL_PREFIX
@@ -6,13 +5,8 @@
from .consumer import ReactpyAsyncWebsocketConsumer
REACTPY_WEBSOCKET_ROUTE = path(
- f"{REACTPY_URL_PREFIX}//",
- URLRouter(
- [
- path("/", ReactpyAsyncWebsocketConsumer.as_asgi()),
- path("", ReactpyAsyncWebsocketConsumer.as_asgi()),
- ]
- ),
+ f"{REACTPY_URL_PREFIX}////",
+ ReactpyAsyncWebsocketConsumer.as_asgi(),
)
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py
index 1dc013f3..dd312195 100644
--- a/tests/test_app/prerender/components.py
+++ b/tests/test_app/prerender/components.py
@@ -54,3 +54,20 @@ def use_user():
return html.div(
{"id": "use-user-ws", "data-success": success}, f"use_user: {user} (WebSocket)"
)
+
+
+@component
+def use_root_id():
+ scope = reactpy_django.hooks.use_scope()
+ root_id = reactpy_django.hooks.use_root_id()
+
+ if scope.get("type") == "http":
+ return html.div(
+ {"id": "use-root-id-http", "data-value": root_id},
+ f"use_root_id: {root_id} (HTTP)",
+ )
+
+ return html.div(
+ {"id": "use-root-id-ws", "data-value": root_id},
+ f"use_root_id: {root_id} (WebSocket)",
+ )
diff --git a/tests/test_app/templates/prerender.html b/tests/test_app/templates/prerender.html
index ed571554..dab4ba01 100644
--- a/tests/test_app/templates/prerender.html
+++ b/tests/test_app/templates/prerender.html
@@ -27,6 +27,8 @@ ReactPy Prerender Test Page
{% component "test_app.prerender.components.use_user" prerender="true" %}
+ {% component "test_app.prerender.components.use_root_id" prerender="true" %}
+