Skip to content

use_root_id hook #230

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

Merged
merged 15 commits into from
Feb 21, 2024
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/examples/python/use-root-id.py
Original file line number Diff line number Diff line change
@@ -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}")
30 changes: 30 additions & 0 deletions docs/src/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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"

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

`#!python None`

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

| 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`.
Expand Down
8 changes: 8 additions & 0 deletions src/reactpy_django/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 5 additions & 10 deletions src/reactpy_django/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import contextlib

from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver


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
Expand All @@ -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()
23 changes: 13 additions & 10 deletions src/reactpy_django/templatetags/reactpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -151,17 +153,15 @@ 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 {
"reactpy_class": class_,
"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,
Expand Down Expand Up @@ -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 ""
),
Expand Down
33 changes: 17 additions & 16 deletions src/reactpy_django/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -166,18 +168,17 @@ 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,
f"Attempt to access invalid ReactPy component: {dotted_path!r}",
)
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),
Expand All @@ -187,22 +188,22 @@ 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:
await asyncio.to_thread(
_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()}",
)
Expand All @@ -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,
)
10 changes: 2 additions & 8 deletions src/reactpy_django/websocket/paths.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
from channels.routing import URLRouter # noqa: E402
from django.urls import path

from reactpy_django.config import REACTPY_URL_PREFIX

from .consumer import ReactpyAsyncWebsocketConsumer

REACTPY_WEBSOCKET_ROUTE = path(
f"{REACTPY_URL_PREFIX}/<dotted_path>/",
URLRouter(
[
path("<uuid>/", ReactpyAsyncWebsocketConsumer.as_asgi()),
path("", ReactpyAsyncWebsocketConsumer.as_asgi()),
]
),
f"{REACTPY_URL_PREFIX}/<str:dotted_path>/<uuid:uuid>/<int:has_args>/",
ReactpyAsyncWebsocketConsumer.as_asgi(),
)
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.

Expand Down
17 changes: 17 additions & 0 deletions tests/test_app/prerender/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
)
2 changes: 2 additions & 0 deletions tests/test_app/templates/prerender.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ <h1>ReactPy Prerender Test Page</h1>
<hr>
{% component "test_app.prerender.components.use_user" prerender="true" %}
<hr>
{% component "test_app.prerender.components.use_root_id" prerender="true" %}
<hr>
</body>

</html>
9 changes: 9 additions & 0 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,15 @@ def test_prerender(self):
string = new_page.locator("#prerender_string")
vdom = new_page.locator("#prerender_vdom")
component = new_page.locator("#prerender_component")
use_root_id_http = new_page.locator("#use-root-id-http")
use_root_id_ws = new_page.locator("#use-root-id-ws")
use_user_http = new_page.locator("#use-user-http[data-success=True]")
use_user_ws = new_page.locator("#use-user-ws[data-success=true]")

string.wait_for()
vdom.wait_for()
component.wait_for()
use_root_id_http.wait_for()
use_user_http.wait_for()

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

# Check if the full render occurred
sleep(1)
self.assertEqual(
string.all_inner_texts(), ["prerender_string: Fully Rendered"]
Expand All @@ -424,7 +430,10 @@ def test_prerender(self):
self.assertEqual(
component.all_inner_texts(), ["prerender_component: Fully Rendered"]
)
use_root_id_ws.wait_for()
use_user_ws.wait_for()
self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value)

finally:
new_page.close()

Expand Down
Loading