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" %} +
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 241e5659..c7a0d2fd 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -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 @@ -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"] @@ -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() diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index b8da31fd..6daa516f 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -63,7 +63,7 @@ def test_component_params(self): def _save_params_to_db(self, value: Any) -> ComponentParams: db = list(self.databases)[0] param_data = ComponentParams((value,), {"test_value": value}) - model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data)) + model = ComponentSession(str(uuid4()), params=pickle.dumps(param_data)) model.clean_fields() model.clean() model.save(using=db) @@ -82,12 +82,12 @@ def test_user_data_cleanup(self): from django.contrib.auth.models import User # Create UserData for real user #1 - user = User.objects.create_user(username=uuid4().hex, password=uuid4().hex) + user = User.objects.create_user(username=str(uuid4()), password=str(uuid4())) user_data = UserDataModel(user_pk=user.pk) user_data.save() # Create UserData for real user #2 - user = User.objects.create_user(username=uuid4().hex, password=uuid4().hex) + user = User.objects.create_user(username=str(uuid4()), password=str(uuid4())) user_data = UserDataModel(user_pk=user.pk) user_data.save() @@ -95,7 +95,7 @@ def test_user_data_cleanup(self): initial_count = UserDataModel.objects.count() # Create UserData for a user that doesn't exist (effectively orphaned) - user_data = UserDataModel(user_pk=uuid4().hex) + user_data = UserDataModel(user_pk=str(uuid4())) user_data.save() # Make sure the orphaned user data object is deleted