diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml
index 3e4f29b0..0a7ae35a 100644
--- a/.github/workflows/test-python.yml
+++ b/.github/workflows/test-python.yml
@@ -29,6 +29,23 @@ jobs:
run: pip install --upgrade pip hatch uv
- name: Run Single DB Tests
run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v
+
+ python-source-multi-db:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - name: Use Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Python Dependencies
+ run: pip install --upgrade pip hatch uv
- name: Run Multi-DB Tests
run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21a31b40..f9a6b332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,11 +21,20 @@ Don't forget to remove deprecated code on each major release!
### Added
+- User login/logout features!
+ - `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components.
+ - `settings.py:REACTPY_AUTH_TOKEN_MAX_AGE` to control the maximum seconds before ReactPy's login token expires.
+ - `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups.
- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component!
+- The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook.
### Changed
-- Refactoring of internal code to improve maintainability. No changes to public/documented API.
+- Refactoring of internal code to improve maintainability. No changes to publicly documented API.
+
+### Fixed
+
+- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they access a freshly logged out Django user object.
## [5.1.1] - 2024-12-02
diff --git a/docs/examples/python/use_auth.py b/docs/examples/python/use_auth.py
new file mode 100644
index 00000000..2bb1bcbb
--- /dev/null
+++ b/docs/examples/python/use_auth.py
@@ -0,0 +1,23 @@
+from django.contrib.auth import get_user_model
+from reactpy import component, html
+
+from reactpy_django.hooks import use_auth, use_user
+
+
+@component
+def my_component():
+ auth = use_auth()
+ user = use_user()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="ExampleUser")
+ await auth.login(new_user)
+
+ async def logout_user(event):
+ await auth.logout()
+
+ return html.div(
+ f"Current User: {user}",
+ html.button({"onClick": login_user}, "Login"),
+ html.button({"onClick": logout_user}, "Logout"),
+ )
diff --git a/docs/examples/python/use_rerender.py b/docs/examples/python/use_rerender.py
new file mode 100644
index 00000000..cd160e17
--- /dev/null
+++ b/docs/examples/python/use_rerender.py
@@ -0,0 +1,15 @@
+from uuid import uuid4
+
+from reactpy import component, html
+
+from reactpy_django.hooks import use_rerender
+
+
+@component
+def my_component():
+ rerender = use_rerender()
+
+ def on_click():
+ rerender()
+
+ return html.div(f"UUID: {uuid4()}", html.button({"onClick": on_click}, "Rerender"))
diff --git a/docs/includes/auth-middleware-stack.md b/docs/includes/auth-middleware-stack.md
new file mode 100644
index 00000000..7cc0c7f8
--- /dev/null
+++ b/docs/includes/auth-middleware-stack.md
@@ -0,0 +1,3 @@
+```python linenums="0"
+{% include "../examples/python/configure_asgi_middleware.py" start="# start" %}
+```
diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md
index 407fe61d..371893e1 100644
--- a/docs/src/learn/add-reactpy-to-a-django-project.md
+++ b/docs/src/learn/add-reactpy-to-a-django-project.md
@@ -87,9 +87,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`
In these situations will need to ensure you are using `#!python AuthMiddlewareStack`.
- ```python linenums="0"
- {% include "../../examples/python/configure_asgi_middleware.py" start="# start" %}
- ```
+ {% include "../../includes/auth-middleware-stack.md" %}
??? question "Where is my `asgi.py`?"
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
index 5826a7b0..89ce805c 100644
--- a/docs/src/reference/hooks.md
+++ b/docs/src/reference/hooks.md
@@ -271,9 +271,86 @@ Mutation functions can be sync or async.
---
+## User Hooks
+
+---
+
+### Use Auth
+
+Provides a `#!python NamedTuple` containing `#!python async login` and `#!python async logout` functions.
+
+This hook utilizes the Django's authentication framework in a way that provides **persistent** login.
+
+=== "components.py"
+
+ ```python
+ {% include "../../examples/python/use_auth.py" %}
+ ```
+
+??? example "See Interface"
+
+ **Parameters**
+
+ `#!python None`
+
+ **Returns**
+
+ | Type | Description |
+ | --- | --- |
+ | `#!python UseAuthTuple` | A named tuple containing `#!python login` and `#!python logout` async functions. |
+
+??? warning "Extra Django configuration required"
+
+ Your ReactPy WebSocket must utilize `#!python AuthMiddlewareStack` in order to use this hook.
+
+ {% include "../../includes/auth-middleware-stack.md" %}
+
+??? question "Why use this instead of `#!python channels.auth.login`?"
+
+ The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, they do not provide persistent authentication when used within ReactPy.
+
+ Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
+
+ To work around this limitation, when `#!python use_auth().login()` is called within your application, ReactPy performs the following process...
+
+ 1. The server authenticates the user into the WebSocket session
+ 2. The server generates a temporary login token linked to the WebSocket session
+ 3. The server commands the browser to fetch the login token via HTTP
+ 4. The client performs the HTTP request
+ 5. The server returns the HTTP response, which contains all necessary cookies
+ 6. The client stores these cookies in the browser
+
+ This ultimately results in persistent authentication which will be retained even if the browser tab is refreshed.
+
+---
+
+### Use User
+
+Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
+
+=== "components.py"
+
+ ```python
+ {% include "../../examples/python/use_user.py" %}
+ ```
+
+??? example "See Interface"
+
+ **Parameters**
+
+ `#!python None`
+
+ **Returns**
+
+ | Type | Description |
+ | --- | --- |
+ | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
+
+---
+
### Use User Data
-Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`.
+Store or retrieve a `#!python dict` containing arbitrary data specific to the connection's `#!python User`.
This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs.
@@ -522,7 +599,7 @@ You can expect this hook to provide strings such as `http://example.com`.
Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection.
-The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset when the page is refreshed.
+The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset only when the page is refreshed.
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)`.
@@ -546,14 +623,14 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use
---
-### Use User
+### Use Re-render
-Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
+Returns a function that can be used to trigger a re-render of the entire component tree.
=== "components.py"
```python
- {% include "../../examples/python/use_user.py" %}
+ {% include "../../examples/python/use_rerender.py" %}
```
??? example "See Interface"
@@ -566,4 +643,4 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
| Type | Description |
| --- | --- |
- | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
+ | `#!python Callable[[], None]` | A function that triggers a re-render of the entire component tree. |
diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md
index 94c9d8b6..50d0b7db 100644
--- a/docs/src/reference/settings.md
+++ b/docs/src/reference/settings.md
@@ -6,12 +6,6 @@ These are ReactPy-Django's default settings values. You can modify these values
-!!! abstract "Note"
-
- The default configuration of ReactPy is suitable for the vast majority of use cases.
-
- You should only consider changing settings when the necessity arises.
-
---
## General Settings
@@ -60,13 +54,17 @@ This file path must be valid to Django's [template finder](https://docs.djangopr
---
+## Authentication Settings
+
+---
+
### `#!python REACTPY_AUTH_BACKEND`
**Default:** `#!python "django.contrib.auth.backends.ModelBackend"`
**Example Value(s):** `#!python "example_project.auth.MyModelBackend"`
-Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
+Dotted path to the Django authentication backend to use for ReactPy components. This is typically needed if:
1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and...
2. You are using `#!python AuthMiddlewareStack` and...
@@ -75,6 +73,22 @@ Dotted path to the Django authentication backend to use for ReactPy components.
---
+### `#!python REACTPY_AUTH_TOKEN_MAX_AGE`
+
+**Default:** `#!python 30`
+
+**Example Value(s):** `#!python 5`
+
+Maximum seconds before ReactPy's login token expires.
+
+This setting exists because Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
+
+To work around this limitation, this setting provides a maximum validity period of a temporary login token. When `#!python reactpy_django.hooks.use_auth().login()` is called within your application, ReactPy will automatically create this temporary login token and command the browser to fetch it via HTTP.
+
+This setting should be a reasonably low value, but still be high enough to account for a combination of client lag, slow internet, and server response time.
+
+---
+
### `#!python REACTPY_AUTO_RELOGIN`
**Default:** `#!python False`
@@ -141,9 +155,9 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne).
**Example Value(s):** `#!python True`
-Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation).
+Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is a relatively slow operation).
-This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient.
+This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place, even though a single render would have been sufficient.
---
@@ -270,6 +284,16 @@ Configures whether ReactPy should clean up expired component sessions during aut
---
+### `#!python REACTPY_CLEAN_AUTH_TOKENS`
+
+**Default:** `#!python True`
+
+**Example Value(s):** `#!python False`
+
+Configures whether ReactPy should clean up expired authentication tokens during automatic clean up operations.
+
+---
+
### `#!python REACTPY_CLEAN_USER_DATA`
**Default:** `#!python True`
diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md
index b7137f87..f41eaf44 100644
--- a/docs/src/reference/template-tag.md
+++ b/docs/src/reference/template-tag.md
@@ -322,7 +322,7 @@ The entire file path provided is loaded directly into the browser, and must have
This template tag configures the current page to be able to run `pyscript`.
-You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment.
+You can optionally use this tag to configure the current PyScript environment, such as adding dependencies.
=== "my_template.html"
diff --git a/pyproject.toml b/pyproject.toml
index 57ee16ad..2354e3a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -148,6 +148,8 @@ extra-dependencies = [
"twisted",
"servestatic",
"django-bootstrap5",
+ "decorator",
+ "playwright",
]
[tool.hatch.envs.django.scripts]
@@ -155,6 +157,15 @@ runserver = [
"cd tests && python manage.py migrate --noinput",
"cd tests && python manage.py runserver",
]
+makemigrations = ["cd tests && python manage.py makemigrations"]
+clean = ["cd tests && python manage.py clean_reactpy -v 3"]
+clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"]
+clean_auth_tokens = [
+ "cd tests && python manage.py clean_reactpy --auth-tokens -v 3",
+]
+clean_user_data = [
+ "cd tests && python manage.py clean_reactpy --user-data -v 3",
+]
#######################################
# >>> Hatch Documentation Scripts <<< #
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
index e5c62f72..176a1f30 100644
--- a/src/js/src/components.ts
+++ b/src/js/src/components.ts
@@ -1,4 +1,4 @@
-import { DjangoFormProps } from "./types";
+import { DjangoFormProps, HttpRequestProps } from "./types";
import React from "react";
import ReactDOM from "react-dom";
/**
@@ -62,3 +62,27 @@ export function DjangoForm({
return null;
}
+
+export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
+ React.useEffect(() => {
+ fetch(url, {
+ method: method,
+ body: body,
+ })
+ .then((response) => {
+ response
+ .text()
+ .then((text) => {
+ callback(response.status, text);
+ })
+ .catch(() => {
+ callback(response.status, "");
+ });
+ })
+ .catch(() => {
+ callback(520, "");
+ });
+ }, []);
+
+ return null;
+}
diff --git a/src/js/src/index.ts b/src/js/src/index.ts
index 1ffff551..01856c7d 100644
--- a/src/js/src/index.ts
+++ b/src/js/src/index.ts
@@ -1,2 +1,2 @@
-export { DjangoForm, bind } from "./components";
+export { HttpRequest, DjangoForm, bind } from "./components";
export { mountComponent } from "./mount";
diff --git a/src/js/src/types.ts b/src/js/src/types.ts
index 79b06375..1f0e2b23 100644
--- a/src/js/src/types.ts
+++ b/src/js/src/types.ts
@@ -23,3 +23,10 @@ export interface DjangoFormProps {
onSubmitCallback: (data: Object) => void;
formId: string;
}
+
+export interface HttpRequestProps {
+ method: string;
+ url: string;
+ body: string;
+ callback: (status: Number, response: string) => void;
+}
diff --git a/src/reactpy_django/auth/__init__.py b/src/reactpy_django/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
new file mode 100644
index 00000000..e0a1e065
--- /dev/null
+++ b/src/reactpy_django/auth/components.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+from logging import getLogger
+from typing import TYPE_CHECKING, Any
+from uuid import uuid4
+
+from django.urls import reverse
+from reactpy import component, hooks
+
+from reactpy_django.javascript_components import HttpRequest
+from reactpy_django.models import AuthToken
+
+if TYPE_CHECKING:
+ from django.contrib.sessions.backends.base import SessionBase
+
+_logger = getLogger(__name__)
+
+
+@component
+def root_manager(child: Any):
+ """This component is serves as the parent component for any ReactPy component tree,
+ which allows for the management of the entire component tree."""
+ scope = hooks.use_connection().scope
+ _, set_rerender = hooks.use_state(uuid4)
+
+ @hooks.use_effect(dependencies=[])
+ def setup_asgi_scope():
+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
+ any relevant actions."""
+ scope["reactpy"]["rerender"] = rerender
+
+ def rerender():
+ """Event that can force a rerender of the entire component tree."""
+ set_rerender(uuid4())
+
+ return child
+
+
+@component
+def auth_manager():
+ """This component uses a client-side component alongside an authentication token
+ to make the client (browser) to switch the HTTP auth session, to make it match the websocket session.
+
+ Used to force persistent authentication between Django's websocket and HTTP stack."""
+ from reactpy_django import config
+
+ sync_needed, set_sync_needed = hooks.use_state(False)
+ token = hooks.use_ref("")
+ scope = hooks.use_connection().scope
+
+ @hooks.use_effect(dependencies=[])
+ def setup_asgi_scope():
+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
+ any relevant actions."""
+ scope["reactpy"]["synchronize_auth"] = synchronize_auth
+
+ @hooks.use_effect(dependencies=[sync_needed])
+ async def synchronize_auth_watchdog():
+ """Detect if the client has taken too long to request a auth session synchronization.
+
+ This effect will automatically be cancelled if the session is successfully
+ synchronized (via effect dependencies)."""
+ if sync_needed:
+ await asyncio.sleep(config.REACTPY_AUTH_TOKEN_MAX_AGE + 0.1)
+ await asyncio.to_thread(
+ _logger.warning,
+ f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_MAX_AGE} (REACTPY_AUTH_TOKEN_MAX_AGE) seconds.",
+ )
+ set_sync_needed(False)
+
+ async def synchronize_auth():
+ """Event that can command the client to switch HTTP auth sessions (to match the websocket session)."""
+ session: SessionBase | None = scope.get("session")
+ if not session or not session.session_key:
+ return
+
+ # Delete previous token to resolve race conditions where...
+ # 1. Login was called multiple times before the first one is completed.
+ # 2. Login was called, but the server failed to respond to the HTTP request.
+ if token.current:
+ with contextlib.suppress(AuthToken.DoesNotExist):
+ obj = await AuthToken.objects.aget(value=token.current)
+ await obj.adelete()
+
+ # Create a fresh token
+ token.set_current(str(uuid4()))
+
+ # Begin the process of synchronizing HTTP and websocket auth sessions
+ obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key)
+ await obj.asave()
+ set_sync_needed(True)
+
+ async def synchronize_auth_callback(status_code: int, response: str):
+ """This callback acts as a communication bridge, allowing the client to notify the server
+ of the status of auth session switch."""
+ set_sync_needed(False)
+ if status_code >= 300 or status_code < 200:
+ await asyncio.to_thread(
+ _logger.error,
+ f"Client returned unexpected HTTP status code ({status_code}) while trying to synchronize authentication sessions.",
+ )
+
+ # If needed, synchronize authenication sessions by configuring all relevant session cookies.
+ # This is achieved by commanding the client to perform a HTTP request to our API endpoint
+ # that will set any required cookies.
+ if sync_needed:
+ return HttpRequest(
+ {
+ "method": "GET",
+ "url": reverse("reactpy:auth_manager", args=[token.current]),
+ "body": None,
+ "callback": synchronize_auth_callback,
+ },
+ )
+
+ return None
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 888cc47d..32f38768 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -1,6 +1,7 @@
import contextlib
import math
import sys
+from uuid import uuid4
from django.contrib.staticfiles.finders import find
from django.core.checks import Error, Tags, Warning, register
@@ -37,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs):
try:
reverse("reactpy:web_modules", kwargs={"file": "example"})
reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"})
+ reverse("reactpy:session_manager", args=[str(uuid4())])
except Exception:
warnings.append(
Warning(
@@ -218,7 +220,7 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
- # Check if REACTPY_CLEAN_SESSION is not a valid property
+ # Check if user misspelled REACTPY_CLEAN_SESSIONS
if getattr(settings, "REACTPY_CLEAN_SESSION", None):
warnings.append(
Warning(
@@ -228,6 +230,27 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
+ # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a large value
+ auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE
+ if isinstance(auth_token_timeout, int) and auth_token_timeout > 120:
+ warnings.append(
+ Warning(
+ "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.",
+ hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.",
+ id="reactpy_django.W020",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value
+ if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2:
+ warnings.append(
+ Warning(
+ "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.",
+ hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.",
+ id="reactpy_django.W021",
+ )
+ )
+
return warnings
@@ -511,4 +534,34 @@ def reactpy_errors(app_configs, **kwargs):
)
)
+ # Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type
+ if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_CLEAN_AUTH_TOKENS.",
+ hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.",
+ id="reactpy_django.E027",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type
+ if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.",
+ hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.",
+ id="reactpy_django.E028",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer
+ if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0:
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.",
+ hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.",
+ id="reactpy_django.E029",
+ )
+ )
+
return errors
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index f4434c4f..cc3ca2fc 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -39,6 +39,11 @@
"REACTPY_SESSION_MAX_AGE",
259200, # Default to 3 days
)
+REACTPY_AUTH_TOKEN_MAX_AGE: int = getattr(
+ settings,
+ "REACTPY_AUTH_TOKEN_MAX_AGE",
+ 30, # Default to 30 seconds
+)
REACTPY_CACHE: str = getattr(
settings,
"REACTPY_CACHE",
@@ -121,6 +126,11 @@
"REACTPY_CLEAN_SESSIONS",
True,
)
+REACTPY_CLEAN_AUTH_TOKENS: bool = getattr(
+ settings,
+ "REACTPY_CLEAN_AUTH_TOKENS",
+ True,
+)
REACTPY_CLEAN_USER_DATA: bool = getattr(
settings,
"REACTPY_CLEAN_USER_DATA",
diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py
index aa39cd0d..9aa99497 100644
--- a/src/reactpy_django/forms/components.py
+++ b/src/reactpy_django/forms/components.py
@@ -49,12 +49,11 @@ def _django_form(
):
from reactpy_django import config
- uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
+ uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
top_children_count = hooks.use_ref(len(top_children))
bottom_children_count = hooks.use_ref(len(bottom_children))
submitted_data, set_submitted_data = hooks.use_state({} or None)
rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None))
- uuid = uuid_ref.current
# Initialize the form with the provided data
validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index c9d783af..6ad3e7d6 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -15,6 +15,7 @@
import orjson
from channels import DEFAULT_CHANNEL_LAYER
+from channels import auth as channels_auth
from channels.layers import InMemoryChannelLayer, get_channel_layer
from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
from reactpy import use_connection as _use_connection
@@ -32,6 +33,7 @@
Mutation,
Query,
SyncPostprocessor,
+ UseAuthTuple,
UserData,
)
from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk
@@ -416,6 +418,41 @@ def use_root_id() -> str:
return scope["reactpy"]["id"]
+def use_rerender() -> Callable[[], None]:
+ """Provides a callable that can re-render the entire component tree without disconnecting the websocket."""
+ scope = use_scope()
+
+ def rerender():
+ scope["reactpy"]["rerender"]()
+
+ return rerender
+
+
+def use_auth() -> UseAuthTuple:
+ """Provides the ability to login/logout a user using Django's authentication framework."""
+ from reactpy_django import config
+
+ scope = use_scope()
+ trigger_rerender = use_rerender()
+
+ async def login(user: AbstractUser, rerender: bool = True) -> None:
+ await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
+ session_save_method = getattr(scope["session"], "asave", scope["session"].save)
+ await ensure_async(session_save_method)()
+ await scope["reactpy"]["synchronize_auth"]()
+
+ if rerender:
+ trigger_rerender()
+
+ async def logout(rerender: bool = True) -> None:
+ await channels_auth.logout(scope)
+
+ if rerender:
+ trigger_rerender()
+
+ return UseAuthTuple(login=login, logout=logout)
+
+
async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None:
"""The mutation function for `use_user_data`"""
from reactpy_django.models import UserDataModel
diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py
index 11f3ec31..499ef2fa 100644
--- a/src/reactpy_django/http/urls.py
+++ b/src/reactpy_django/http/urls.py
@@ -15,4 +15,9 @@
views.view_to_iframe,
name="view_to_iframe",
),
+ path(
+ "auth/",
+ views.auth_manager,
+ name="auth_manager",
+ ),
]
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index e0c5fc1d..7a16ba2f 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -5,7 +5,7 @@
from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
from reactpy.config import REACTPY_WEB_MODULES_DIR
-from reactpy_django.utils import FileAsyncIterator, render_view
+from reactpy_django.utils import FileAsyncIterator, ensure_async, render_view
def web_modules_file(request: HttpRequest, file: str) -> FileResponse:
@@ -42,3 +42,43 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
# Ensure page can be rendered as an iframe
response["X-Frame-Options"] = "SAMEORIGIN"
return response
+
+
+async def auth_manager(request: HttpRequest, uuid: str) -> HttpResponse:
+ """Switches the client's active auth session to match ReactPy's session.
+
+ This view exists because ReactPy is rendered via WebSockets, and browsers do not
+ allow active WebSocket connections to modify cookies. Django's authentication
+ design requires HTTP cookies to persist state changes.
+ """
+ from reactpy_django.models import AuthToken
+
+ # Find out what session the client wants to switch to
+ token = await AuthToken.objects.aget(value=uuid)
+
+ # CHECK: Token has expired?
+ if token.expired:
+ msg = "Session expired."
+ await token.adelete()
+ raise SuspiciousOperation(msg)
+
+ # CHECK: Token does not exist?
+ exists_method = getattr(request.session, "aexists", request.session.exists)
+ if not await ensure_async(exists_method)(token.session_key):
+ msg = "Attempting to switch to a session that does not exist."
+ raise SuspiciousOperation(msg)
+
+ # CHECK: Client already using the correct session key?
+ if request.session.session_key == token.session_key:
+ await token.adelete()
+ return HttpResponse(status=204)
+
+ # Switch the client's session
+ request.session = type(request.session)(session_key=token.session_key)
+ load_method = getattr(request.session, "aload", request.session.load)
+ await ensure_async(load_method)()
+ request.session.modified = True
+ save_method = getattr(request.session, "asave", request.session.save)
+ await ensure_async(save_method)()
+ await token.adelete()
+ return HttpResponse(status=204)
diff --git a/src/reactpy_django/javascript_components.py b/src/reactpy_django/javascript_components.py
new file mode 100644
index 00000000..eb4fa035
--- /dev/null
+++ b/src/reactpy_django/javascript_components.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from reactpy import web
+
+HttpRequest = web.export(
+ web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
+ ("HttpRequest"),
+)
diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py
index acfd7976..804d5a3e 100644
--- a/src/reactpy_django/management/commands/clean_reactpy.py
+++ b/src/reactpy_django/management/commands/clean_reactpy.py
@@ -1,5 +1,4 @@
from logging import getLogger
-from typing import Literal
from django.core.management.base import BaseCommand
@@ -9,18 +8,12 @@
class Command(BaseCommand):
help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations."
- def handle(self, **options):
- from reactpy_django.tasks import clean
+ def handle(self, *_args, **options):
+ from reactpy_django.tasks import CleaningArgs, clean
- verbosity = options.get("verbosity", 1)
-
- cleaning_args: set[Literal["all", "sessions", "user_data"]] = set()
- if options.get("sessions"):
- cleaning_args.add("sessions")
- if options.get("user_data"):
- cleaning_args.add("user_data")
- if not cleaning_args:
- cleaning_args = {"all"}
+ verbosity = options.pop("verbosity", 1)
+ valid_args: set[CleaningArgs] = {"all", "sessions", "auth_tokens", "user_data"}
+ cleaning_args: set[CleaningArgs] = {arg for arg in options if arg in valid_args and options[arg]} or {"all"}
clean(*cleaning_args, immediate=True, verbosity=verbosity)
@@ -31,10 +24,15 @@ def add_arguments(self, parser):
parser.add_argument(
"--sessions",
action="store_true",
- help="Clean session data. This value can be combined with other cleaning options.",
+ help="Clean component session data. This value can be combined with other cleaning options.",
)
parser.add_argument(
"--user-data",
action="store_true",
help="Clean user data. This value can be combined with other cleaning options.",
)
+ parser.add_argument(
+ "--auth-tokens",
+ action="store_true",
+ help="Clean authentication tokens. This value can be combined with other cleaning options.",
+ )
diff --git a/src/reactpy_django/migrations/0007_authtoken.py b/src/reactpy_django/migrations/0007_authtoken.py
new file mode 100644
index 00000000..49b06b0a
--- /dev/null
+++ b/src/reactpy_django/migrations/0007_authtoken.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.1.4 on 2024-12-29 07:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reactpy_django', '0006_userdatamodel'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AuthToken',
+ fields=[
+ ('value', models.UUIDField(editable=False, primary_key=True, serialize=False, unique=True)),
+ ('session_key', models.CharField(editable=False, max_length=40)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 15f07595..ab143736 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -1,19 +1,44 @@
+from datetime import timedelta
+
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
+from django.utils import timezone
from reactpy_django.utils import get_pk
class ComponentSession(models.Model):
- """A model for storing component sessions."""
+ """A model for storing component sessions.
+
+ This is used to store component arguments provided within Django templates.
+ These arguments are retrieved within the layout renderer (WebSocket consumer)."""
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
params = models.BinaryField(editable=False)
last_accessed = models.DateTimeField(auto_now=True)
+class AuthToken(models.Model):
+ """A model that contains any relevant data needed to force Django's HTTP session to
+ match the websocket session.
+
+ The session key is tied to an arbitrary UUID token for security (obfuscation) purposes.
+
+ Source code must be written to respect the expiration property of this model."""
+
+ value = models.UUIDField(primary_key=True, editable=False, unique=True)
+ session_key = models.CharField(max_length=40, editable=False)
+ created_at = models.DateTimeField(auto_now_add=True, editable=False)
+
+ @property
+ def expired(self) -> bool:
+ from reactpy_django.config import REACTPY_AUTH_TOKEN_MAX_AGE
+
+ return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE))
+
+
class Config(models.Model):
"""A singleton model for storing ReactPy configuration."""
diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py
index 00db19e4..255b6354 100644
--- a/src/reactpy_django/pyscript/components.py
+++ b/src/reactpy_django/pyscript/components.py
@@ -20,8 +20,7 @@ def _pyscript_component(
root: str = "root",
):
rendered, set_rendered = hooks.use_state(False)
- uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
- uuid = uuid_ref.current
+ uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
initial = reactpy_to_string(initial, uuid=uuid)
executor = render_pyscript_template(file_paths, uuid, root)
diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py
index facf3b82..44d4eb14 100644
--- a/src/reactpy_django/tasks.py
+++ b/src/reactpy_django/tasks.py
@@ -13,37 +13,41 @@
from reactpy_django.models import Config
CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo)
+CleaningArgs = Literal["all", "sessions", "auth_tokens", "user_data"]
-def clean(
- *args: Literal["all", "sessions", "user_data"],
- immediate: bool = False,
- verbosity: int = 1,
-):
+def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
from reactpy_django.config import (
+ REACTPY_CLEAN_AUTH_TOKENS,
REACTPY_CLEAN_SESSIONS,
REACTPY_CLEAN_USER_DATA,
)
from reactpy_django.models import Config
config = Config.load()
- if immediate or is_clean_needed(config):
+ if immediate or clean_is_needed(config):
config.cleaned_at = timezone.now()
config.save()
+
+ # If no args are provided, use the default settings.
sessions = REACTPY_CLEAN_SESSIONS
+ auth_tokens = REACTPY_CLEAN_AUTH_TOKENS
user_data = REACTPY_CLEAN_USER_DATA
if args:
sessions = any(value in args for value in ("sessions", "all"))
+ auth_tokens = any(value in args for value in ("auth_tokens", "all"))
user_data = any(value in args for value in ("user_data", "all"))
if sessions:
- clean_sessions(verbosity)
+ clean_component_sessions(verbosity)
+ if auth_tokens:
+ clean_auth_tokens(verbosity)
if user_data:
clean_user_data(verbosity)
-def clean_sessions(verbosity: int = 1):
+def clean_component_sessions(verbosity: int = 1):
"""Deletes expired component sessions from the database.
As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds.
"""
@@ -67,6 +71,25 @@ def clean_sessions(verbosity: int = 1):
inspect_clean_duration(start_time, "component sessions", verbosity)
+def clean_auth_tokens(verbosity: int = 1):
+ from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_TOKEN_MAX_AGE
+ from reactpy_django.models import AuthToken
+
+ if verbosity >= 2:
+ _logger.info("Cleaning ReactPy auth tokens...")
+ start_time = timezone.now()
+ expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE)
+ synchronizer_objects = AuthToken.objects.filter(created_at__lte=expiration_date)
+
+ if verbosity >= 2:
+ _logger.info("Deleting %d expired auth token objects...", synchronizer_objects.count())
+
+ synchronizer_objects.delete()
+
+ if DJANGO_DEBUG or verbosity >= 2:
+ inspect_clean_duration(start_time, "auth tokens", verbosity)
+
+
def clean_user_data(verbosity: int = 1):
"""Delete any user data that is not associated with an existing `User`.
This is a safety measure to ensure that we don't have any orphaned data in the database.
@@ -101,7 +124,7 @@ def clean_user_data(verbosity: int = 1):
inspect_clean_duration(start_time, "user data", verbosity)
-def is_clean_needed(config: Config | None = None) -> bool:
+def clean_is_needed(config: Config | None = None) -> bool:
"""Check if a clean is needed. This function avoids unnecessary database reads by caching the
CLEAN_NEEDED_BY date."""
from reactpy_django.config import REACTPY_CLEAN_INTERVAL
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 029e7d8c..4b85e216 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -144,6 +144,7 @@ def component(
)
_logger.error(msg)
return failure_context(dotted_path, ComponentCarrierError(msg))
+
_prerender_html = prerender_component(user_component, args, kwargs, uuid, request)
# Fetch the offline component's HTML, if requested
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index 83cabf5b..2703523d 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -19,6 +19,7 @@
if TYPE_CHECKING:
from collections.abc import MutableMapping, Sequence
+ from django.contrib.auth.models import AbstractUser
from django.forms import Form, ModelForm
from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer
@@ -108,3 +109,26 @@ def __call__(
class ViewToIframeConstructor(Protocol):
def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ...
+
+
+class UseAuthLogin(Protocol):
+ async def __call__(self, user: AbstractUser, rerender: bool = True) -> None: ...
+
+
+class UseAuthLogout(Protocol):
+ async def __call__(self, rerender: bool = True) -> None: ...
+
+
+class UseAuthTuple(NamedTuple):
+ login: UseAuthLogin
+ """Login a user.
+
+ Args:
+ user: The user to login.
+ rerender: If True, the root component will be re-rendered after the user is logged in."""
+
+ logout: UseAuthLogout
+ """Logout the current user.
+
+ Args:
+ rerender: If True, the root component will be re-rendered after the user is logged out."""
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 1ea4f0ea..f126bd61 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
import contextlib
import inspect
import logging
@@ -16,7 +17,6 @@
from uuid import UUID, uuid4
import dill
-from asgiref.sync import async_to_sync
from channels.db import database_sync_to_async
from django.contrib.staticfiles.finders import find
from django.core.cache import caches
@@ -62,6 +62,7 @@
+ r"\s*%}"
)
FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator")
+SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout")
async def render_view(
@@ -353,14 +354,16 @@ class SyncLayout(Layout):
"""
def __enter__(self):
- async_to_sync(self.__aenter__)()
+ self.loop = asyncio.new_event_loop()
+ SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aenter__()).result()
return self
- def __exit__(self, *_):
- async_to_sync(self.__aexit__)(*_)
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result()
+ self.loop.close()
def sync_render(self):
- return async_to_sync(super().render)()
+ return SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.render()).result()
def get_pk(model):
@@ -395,6 +398,7 @@ def prerender_component(
search = request.GET.urlencode()
scope = getattr(request, "scope", {})
scope["reactpy"] = {"id": str(uuid)}
+ dir(request.user) # Call `dir` before prerendering to make sure the user object is loaded
with SyncLayout(
ConnectionContext(
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 4e7f3578..47ccc717 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -142,7 +142,9 @@ async def encode_json(cls, content):
async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
+ # TODO: Figure out why exceptions raised in this method are not being printed to the console.
from reactpy_django import models
+ from reactpy_django.auth.components import auth_manager, root_manager
from reactpy_django.config import (
REACTPY_REGISTERED_COMPONENTS,
REACTPY_SESSION_MAX_AGE,
@@ -210,7 +212,13 @@ async def run_dispatcher(self):
# Start the ReactPy component rendering loop
with contextlib.suppress(Exception):
await serve_layout(
- Layout(ConnectionContext(root_component, value=connection)), # type: ignore
+ Layout( # type: ignore
+ ConnectionContext(
+ auth_manager(),
+ root_manager(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 7ed5d900..65346b83 100644
--- a/src/reactpy_django/websocket/paths.py
+++ b/src/reactpy_django/websocket/paths.py
@@ -9,6 +9,6 @@
)
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
-Required since the `reverse()` function does not exist for Django Channels, but ReactPy needs
-to know the current websocket path.
+This global exists since there is no way to retrieve (`reverse()`) a Django Channels URL,
+but ReactPy-Django needs to know the current websocket path.
"""
diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py
index fa162b21..fab91fe0 100644
--- a/tests/test_app/__init__.py
+++ b/tests/test_app/__init__.py
@@ -8,7 +8,7 @@
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
assert (
subprocess.run(
- ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")],
+ ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js"), "--minify"],
cwd=str(js_dir),
check=True,
).returncode
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 38b3f552..b2f075f3 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -1,6 +1,7 @@
import asyncio
import inspect
from pathlib import Path
+from uuid import uuid4
from channels.auth import login, logout
from channels.db import database_sync_to_async
@@ -692,3 +693,86 @@ async def on_submit(event):
html.div(f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})"),
html.div(html.input({"on_key_press": on_submit, "placeholder": "Type here to add data"})),
)
+
+
+@component
+def use_auth():
+ _login, _logout = reactpy_django.hooks.use_auth()
+ uuid = hooks.use_ref(str(uuid4())).current
+ current_user = reactpy_django.hooks.use_user()
+ connection = reactpy_django.hooks.use_connection()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="user_4")
+ await _login(new_user)
+
+ async def logout_user(event):
+ await _logout()
+
+ async def disconnect(event):
+ await connection.carrier.close()
+
+ return html.div(
+ {
+ "id": "use-auth",
+ "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
+ "data-uuid": uuid,
+ },
+ html.div("use_auth"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"className": "login", "on_click": login_user}, "Login"),
+ html.button({"className": "logout", "on_click": logout_user}, "Logout"),
+ html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
+ html.div(f"User: {current_user}"),
+ )
+
+
+@component
+def use_auth_no_rerender():
+ _login, _logout = reactpy_django.hooks.use_auth()
+ uuid = hooks.use_ref(str(uuid4())).current
+ current_user = reactpy_django.hooks.use_user()
+ connection = reactpy_django.hooks.use_connection()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="user_5")
+ await _login(new_user, rerender=False)
+
+ async def logout_user(event):
+ await _logout(rerender=False)
+
+ async def disconnect(event):
+ await connection.carrier.close()
+
+ return html.div(
+ {
+ "id": "use-auth-no-rerender",
+ "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
+ "data-uuid": uuid,
+ },
+ html.div("use_auth_no_rerender"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"className": "login", "on_click": login_user}, "Login"),
+ html.button({"className": "logout", "on_click": logout_user}, "Logout"),
+ html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
+ html.div(f"User: {current_user}"),
+ )
+
+
+@component
+def use_rerender():
+ uuid = str(uuid4())
+ rerender = reactpy_django.hooks.use_rerender()
+
+ def on_click(event):
+ rerender()
+
+ return html.div(
+ {
+ "id": "use-rerender",
+ "data-uuid": uuid,
+ },
+ html.div("use_rerender"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"on_click": on_click}, "Rerender"),
+ )
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 117e867d..aaef6cb7 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -3,90 +3,96 @@
-
-
-
-
- ReactPy
-
+
+
+
+
+ ReactPy
+
-