Skip to content

Commit c4e55b3

Browse files
authored
use_root_id hook (#230)
- fix #203 - Some miscellaneous refactoring on the websocket `consumer` and `UserDataModel`, just because I happened to notice some cleanup potential while making this PR.
1 parent 06114f3 commit c4e55b3

File tree

12 files changed

+121
-48
lines changed

12 files changed

+121
-48
lines changed

Diff for: CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,17 @@ Using the following categories, list your changes in this order:
3737
### Added
3838

3939
- Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook.
40+
- Access to the root component's `id` via the `reactpy_django.hooks.use_root_id` hook.
4041
- More robust control over ReactPy clean up tasks!
4142
- `settings.py:REACTPY_CLEAN_INTERVAL` to control how often ReactPy automatically performs cleaning tasks.
4243
- `settings.py:REACTPY_CLEAN_SESSIONS` to control whether ReactPy automatically cleans up expired sessions.
4344
- `settings.py:REACTPY_CLEAN_USER_DATA` to control whether ReactPy automatically cleans up orphaned user data.
4445
- `python manage.py clean_reactpy` command to manually perform ReactPy clean up tasks.
4546

47+
### Changed
48+
49+
- Simplified code for cascading deletion of UserData.
50+
4651
## [3.7.0] - 2024-01-30
4752

4853
### Added

Diff for: docs/examples/python/use-root-id.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from reactpy import component, html
2+
from reactpy_django.hooks import use_root_id
3+
4+
5+
@component
6+
def my_component():
7+
root_id = use_root_id()
8+
9+
return html.div(f"Root ID: {root_id}")

Diff for: docs/src/reference/hooks.md

+30
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,8 @@ Shortcut that returns the browser's current `#!python Location`.
492492
| --- | --- |
493493
| `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. |
494494

495+
---
496+
495497
### Use Origin
496498

497499
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`.
518520

519521
---
520522

523+
### Use Root ID
524+
525+
Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection.
526+
527+
The root ID is currently a randomly generated `#!python uuid4` (unique across all root component).
528+
529+
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)`.
530+
531+
=== "components.py"
532+
533+
```python
534+
{% include "../../examples/python/use-root-id.py" %}
535+
```
536+
537+
??? example "See Interface"
538+
539+
<font size="4">**Parameters**</font>
540+
541+
`#!python None`
542+
543+
<font size="4">**Returns**</font>
544+
545+
| Type | Description |
546+
| --- | --- |
547+
| `#!python str` | A string containing the root component's `#!python id`. |
548+
549+
---
550+
521551
### Use User
522552

523553
Shortcut that returns the WebSocket or HTTP connection's `#!python User`.

Diff for: src/reactpy_django/hooks.py

+8
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,14 @@ async def message_sender(message: dict):
436436
return message_sender
437437

438438

439+
def use_root_id() -> str:
440+
"""Get the root element's ID. This value is guaranteed to be unique. Current versions of \
441+
ReactPy-Django return a `uuid4` string."""
442+
scope = use_scope()
443+
444+
return scope["reactpy"]["id"]
445+
446+
439447
def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs):
440448
return options, query, args, kwargs
441449

Diff for: src/reactpy_django/models.py

+5-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import contextlib
2-
31
from django.contrib.auth import get_user_model
42
from django.db import models
53
from django.db.models.signals import pre_delete
64
from django.dispatch import receiver
75

86

97
class ComponentSession(models.Model):
10-
"""A model for storing component sessions.
11-
All queries must be routed through `reactpy_django.config.REACTPY_DATABASE`.
12-
"""
8+
"""A model for storing component sessions."""
139

1410
uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore
1511
params = models.BinaryField(editable=False) # type: ignore
@@ -36,16 +32,15 @@ class UserDataModel(models.Model):
3632
"""A model for storing `user_state` data."""
3733

3834
# We can't store User as a ForeignKey/OneToOneField because it may not be in the same database
39-
# and Django does not allow cross-database relations. Also, we can't know the type of the UserModel PK,
40-
# so we store it as a string.
35+
# and Django does not allow cross-database relations. Also, since we can't know the type of the UserModel PK,
36+
# we store it as a string to normalize.
4137
user_pk = models.CharField(max_length=255, unique=True) # type: ignore
4238
data = models.BinaryField(null=True, blank=True) # type: ignore
4339

4440

4541
@receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data")
4642
def delete_user_data(sender, instance, **kwargs):
47-
"""Delete `UserDataModel` when the `User` is deleted."""
43+
"""Delete ReactPy's `UserDataModel` when a Django `User` is deleted."""
4844
pk = getattr(instance, instance._meta.pk.name)
4945

50-
with contextlib.suppress(Exception):
51-
UserDataModel.objects.get(user_pk=pk).delete()
46+
UserDataModel.objects.filter(user_pk=pk).delete()

Diff for: src/reactpy_django/templatetags/reactpy.py

+13-10
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ def component(
7777
or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "")
7878
).strip("/")
7979
is_local = not host or host.startswith(perceived_host)
80-
uuid = uuid4().hex
80+
uuid = str(uuid4())
8181
class_ = kwargs.pop("class", "")
82-
component_has_args = args or kwargs
82+
has_args = bool(args or kwargs)
8383
user_component: ComponentConstructor | None = None
8484
_prerender_html = ""
8585
_offline_html = ""
@@ -108,7 +108,7 @@ def component(
108108
return failure_context(dotted_path, e)
109109

110110
# Store args & kwargs in the database (fetched by our websocket later)
111-
if component_has_args:
111+
if has_args:
112112
try:
113113
save_component_params(args, kwargs, uuid)
114114
except Exception as e:
@@ -135,7 +135,9 @@ def component(
135135
)
136136
_logger.error(msg)
137137
return failure_context(dotted_path, ComponentCarrierError(msg))
138-
_prerender_html = prerender_component(user_component, args, kwargs, request)
138+
_prerender_html = prerender_component(
139+
user_component, args, kwargs, uuid, request
140+
)
139141

140142
# Fetch the offline component's HTML, if requested
141143
if offline:
@@ -151,17 +153,15 @@ def component(
151153
)
152154
_logger.error(msg)
153155
return failure_context(dotted_path, ComponentCarrierError(msg))
154-
_offline_html = prerender_component(offline_component, [], {}, request)
156+
_offline_html = prerender_component(offline_component, [], {}, uuid, request)
155157

156158
# Return the template rendering context
157159
return {
158160
"reactpy_class": class_,
159161
"reactpy_uuid": uuid,
160162
"reactpy_host": host or perceived_host,
161163
"reactpy_url_prefix": config.REACTPY_URL_PREFIX,
162-
"reactpy_component_path": f"{dotted_path}/{uuid}/"
163-
if component_has_args
164-
else f"{dotted_path}/",
164+
"reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
165165
"reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
166166
"reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL,
167167
"reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
@@ -199,14 +199,17 @@ def validate_host(host: str):
199199

200200

201201
def prerender_component(
202-
user_component: ComponentConstructor, args, kwargs, request: HttpRequest
202+
user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest
203203
):
204204
search = request.GET.urlencode()
205+
scope = getattr(request, "scope", {})
206+
scope["reactpy"] = {"id": str(uuid)}
207+
205208
with SyncLayout(
206209
ConnectionContext(
207210
user_component(*args, **kwargs),
208211
value=Connection(
209-
scope=getattr(request, "scope", {}),
212+
scope=scope,
210213
location=Location(
211214
pathname=request.path, search=f"?{search}" if search else ""
212215
),

Diff for: src/reactpy_django/websocket/consumer.py

+17-16
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@
3030
from django.contrib.auth.models import AbstractUser
3131

3232
_logger = logging.getLogger(__name__)
33-
backhaul_loop = asyncio.new_event_loop()
33+
BACKHAUL_LOOP = asyncio.new_event_loop()
3434

3535

3636
def start_backhaul_loop():
3737
"""Starts the asyncio event loop that will perform component rendering tasks."""
38-
asyncio.set_event_loop(backhaul_loop)
39-
backhaul_loop.run_forever()
38+
asyncio.set_event_loop(BACKHAUL_LOOP)
39+
BACKHAUL_LOOP.run_forever()
4040

4141

42-
backhaul_thread = Thread(
42+
BACKHAUL_THREAD = Thread(
4343
target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul"
4444
)
4545

@@ -83,13 +83,13 @@ async def connect(self) -> None:
8383
self.threaded = REACTPY_BACKHAUL_THREAD
8484
self.component_session: models.ComponentSession | None = None
8585
if self.threaded:
86-
if not backhaul_thread.is_alive():
86+
if not BACKHAUL_THREAD.is_alive():
8787
await asyncio.to_thread(
8888
_logger.debug, "Starting ReactPy backhaul thread."
8989
)
90-
backhaul_thread.start()
90+
BACKHAUL_THREAD.start()
9191
self.dispatcher = asyncio.run_coroutine_threadsafe(
92-
self.run_dispatcher(), backhaul_loop
92+
self.run_dispatcher(), BACKHAUL_LOOP
9393
)
9494
else:
9595
self.dispatcher = asyncio.create_task(self.run_dispatcher())
@@ -127,7 +127,7 @@ async def receive_json(self, content: Any, **_) -> None:
127127
"""Receive a message from the browser. Typically, messages are event signals."""
128128
if self.threaded:
129129
asyncio.run_coroutine_threadsafe(
130-
self.recv_queue.put(content), backhaul_loop
130+
self.recv_queue.put(content), BACKHAUL_LOOP
131131
)
132132
else:
133133
await self.recv_queue.put(content)
@@ -151,6 +151,8 @@ async def run_dispatcher(self):
151151
scope = self.scope
152152
self.dotted_path = dotted_path = scope["url_route"]["kwargs"]["dotted_path"]
153153
uuid = scope["url_route"]["kwargs"].get("uuid")
154+
has_args = scope["url_route"]["kwargs"].get("has_args")
155+
scope["reactpy"] = {"id": str(uuid)}
154156
query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True)
155157
http_pathname = query_string.get("http_pathname", [""])[0]
156158
http_search = query_string.get("http_search", [""])[0]
@@ -166,18 +168,17 @@ async def run_dispatcher(self):
166168

167169
# Verify the component has already been registered
168170
try:
169-
component_constructor = REACTPY_REGISTERED_COMPONENTS[dotted_path]
171+
root_component_constructor = REACTPY_REGISTERED_COMPONENTS[dotted_path]
170172
except KeyError:
171173
await asyncio.to_thread(
172174
_logger.warning,
173175
f"Attempt to access invalid ReactPy component: {dotted_path!r}",
174176
)
175177
return
176178

177-
# Fetch the component's args/kwargs from the database, if needed
179+
# Construct the component. This may require fetching the component's args/kwargs from the database.
178180
try:
179-
if uuid:
180-
# Get the component session from the DB
181+
if has_args:
181182
self.component_session = await models.ComponentSession.objects.aget(
182183
uuid=uuid,
183184
last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE),
@@ -187,22 +188,22 @@ async def run_dispatcher(self):
187188
component_session_kwargs = params.kwargs
188189

189190
# Generate the initial component instance
190-
component_instance = component_constructor(
191+
root_component = root_component_constructor(
191192
*component_session_args, **component_session_kwargs
192193
)
193194
except models.ComponentSession.DoesNotExist:
194195
await asyncio.to_thread(
195196
_logger.warning,
196197
f"Component session for '{dotted_path}:{uuid}' not found. The "
197198
"session may have already expired beyond REACTPY_SESSION_MAX_AGE. "
198-
"If you are using a custom host, you may have forgotten to provide "
199+
"If you are using a custom `host`, you may have forgotten to provide "
199200
"args/kwargs.",
200201
)
201202
return
202203
except Exception:
203204
await asyncio.to_thread(
204205
_logger.error,
205-
f"Failed to construct component {component_constructor} "
206+
f"Failed to construct component {root_component_constructor} "
206207
f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!\n"
207208
f"{traceback.format_exc()}",
208209
)
@@ -211,7 +212,7 @@ async def run_dispatcher(self):
211212
# Start the ReactPy component rendering loop
212213
with contextlib.suppress(Exception):
213214
await serve_layout(
214-
Layout(ConnectionContext(component_instance, value=connection)),
215+
Layout(ConnectionContext(root_component, value=connection)),
215216
self.send_json,
216217
self.recv_queue.get,
217218
)

Diff for: src/reactpy_django/websocket/paths.py

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
from channels.routing import URLRouter # noqa: E402
21
from django.urls import path
32

43
from reactpy_django.config import REACTPY_URL_PREFIX
54

65
from .consumer import ReactpyAsyncWebsocketConsumer
76

87
REACTPY_WEBSOCKET_ROUTE = path(
9-
f"{REACTPY_URL_PREFIX}/<dotted_path>/",
10-
URLRouter(
11-
[
12-
path("<uuid>/", ReactpyAsyncWebsocketConsumer.as_asgi()),
13-
path("", ReactpyAsyncWebsocketConsumer.as_asgi()),
14-
]
15-
),
8+
f"{REACTPY_URL_PREFIX}/<str:dotted_path>/<uuid:uuid>/<int:has_args>/",
9+
ReactpyAsyncWebsocketConsumer.as_asgi(),
1610
)
1711
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
1812

Diff for: tests/test_app/prerender/components.py

+17
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,20 @@ def use_user():
5454
return html.div(
5555
{"id": "use-user-ws", "data-success": success}, f"use_user: {user} (WebSocket)"
5656
)
57+
58+
59+
@component
60+
def use_root_id():
61+
scope = reactpy_django.hooks.use_scope()
62+
root_id = reactpy_django.hooks.use_root_id()
63+
64+
if scope.get("type") == "http":
65+
return html.div(
66+
{"id": "use-root-id-http", "data-value": root_id},
67+
f"use_root_id: {root_id} (HTTP)",
68+
)
69+
70+
return html.div(
71+
{"id": "use-root-id-ws", "data-value": root_id},
72+
f"use_root_id: {root_id} (WebSocket)",
73+
)

Diff for: tests/test_app/templates/prerender.html

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ <h1>ReactPy Prerender Test Page</h1>
2727
<hr>
2828
{% component "test_app.prerender.components.use_user" prerender="true" %}
2929
<hr>
30+
{% component "test_app.prerender.components.use_root_id" prerender="true" %}
31+
<hr>
3032
</body>
3133

3234
</html>

Diff for: tests/test_app/tests/test_components.py

+9
Original file line numberDiff line numberDiff line change
@@ -399,12 +399,15 @@ def test_prerender(self):
399399
string = new_page.locator("#prerender_string")
400400
vdom = new_page.locator("#prerender_vdom")
401401
component = new_page.locator("#prerender_component")
402+
use_root_id_http = new_page.locator("#use-root-id-http")
403+
use_root_id_ws = new_page.locator("#use-root-id-ws")
402404
use_user_http = new_page.locator("#use-user-http[data-success=True]")
403405
use_user_ws = new_page.locator("#use-user-ws[data-success=true]")
404406

405407
string.wait_for()
406408
vdom.wait_for()
407409
component.wait_for()
410+
use_root_id_http.wait_for()
408411
use_user_http.wait_for()
409412

410413
# Check if the prerender occurred
@@ -415,7 +418,10 @@ def test_prerender(self):
415418
self.assertEqual(
416419
component.all_inner_texts(), ["prerender_component: Prerendered"]
417420
)
421+
root_id_value = use_root_id_http.get_attribute("data-value")
422+
self.assertEqual(len(root_id_value), 36)
418423

424+
# Check if the full render occurred
419425
sleep(1)
420426
self.assertEqual(
421427
string.all_inner_texts(), ["prerender_string: Fully Rendered"]
@@ -424,7 +430,10 @@ def test_prerender(self):
424430
self.assertEqual(
425431
component.all_inner_texts(), ["prerender_component: Fully Rendered"]
426432
)
433+
use_root_id_ws.wait_for()
427434
use_user_ws.wait_for()
435+
self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value)
436+
428437
finally:
429438
new_page.close()
430439

0 commit comments

Comments
 (0)