From edbbdfd2ba6d61af49774c3651b048980e69ee95 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:53:30 -0700 Subject: [PATCH 01/26] Add persistent session state settings --- src/reactpy_django/config.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 0b71ec17..0ccc6f3c 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,7 +1,7 @@ from __future__ import annotations from itertools import cycle -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, Literal from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS @@ -136,6 +136,27 @@ "REACTPY_CLEAN_USER_DATA", True, ) +REACTPY_CLEAN_SESSION_STATE: bool = getattr( + settings, + "REACTPY_CLEAN_SESSION_STATE", + True, +) +SessionStateMode = Literal["tab", "user"] +REACTPY_SESSION_STATE_MODE: SessionStateMode = getattr( + settings, + "REACTPY_SESSION_STATE_MODE", + "tab", # Default to per-tab (per-component token) scope +) +REACTPY_SESSION_STATE_SYNC_INTERVAL: int = getattr( + settings, + "REACTPY_SESSION_STATE_SYNC_INTERVAL", + 5, # Default to 5 seconds +) +REACTPY_SESSION_STATE_MAX_AGE: int = getattr( + settings, + "REACTPY_SESSION_STATE_MAX_AGE", + 259200, # Default to 3 days +) REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr( settings, "REACTPY_DEFAULT_FORM_TEMPLATE", From 1a4beca3ef269b36967c671209b208088e70d9c1 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:53:52 -0700 Subject: [PATCH 02/26] Add SessionStateModel for persistent session state --- src/reactpy_django/models.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index ab143736..82b7921a 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -65,6 +65,29 @@ class UserDataModel(models.Model): data = models.BinaryField(null=True, blank=True) +class SessionStateModel(models.Model): + """A model for storing `session_state` data. + + This is used to persistently store ReactPy state across WebSocket reconnects (and, depending + on the configured `REACTPY_SESSION_STATE_MODE`, optionally across page reloads as well). + + The `scope_id` uniquely identifies the scope that the state belongs to. By default it is a + per-component (per-tab) token that is stable across WebSocket reconnects. When + `REACTPY_SESSION_STATE_MODE` is set to `"user"`, the scope is instead the authenticated user's + primary key (falling back to a per-tab token for anonymous users).""" + + scope_id = models.CharField(max_length=255) + key = models.CharField(max_length=255) + data = models.BinaryField(null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("scope_id", "key") + indexes = [ + models.Index(fields=["scope_id", "updated_at"]), + ] + + @receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data") def delete_user_data(sender, instance, **kwargs): """Delete ReactPy's `UserDataModel` when a Django `User` is deleted.""" From 978436cf8bf9ac07365da96ce8653ac3616c6d42 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:54:06 -0700 Subject: [PATCH 03/26] Add SessionStateModel migration --- .../migrations/0008_sessionstatemodel.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/reactpy_django/migrations/0008_sessionstatemodel.py diff --git a/src/reactpy_django/migrations/0008_sessionstatemodel.py b/src/reactpy_django/migrations/0008_sessionstatemodel.py new file mode 100644 index 00000000..8bb7976e --- /dev/null +++ b/src/reactpy_django/migrations/0008_sessionstatemodel.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2026-09-14 22:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reactpy_django', '0007_authtoken'), + ] + + operations = [ + migrations.CreateModel( + name='SessionStateModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scope_id', models.CharField(max_length=255)), + ('key', models.CharField(max_length=255)), + ('data', models.BinaryField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'indexes': [models.Index(fields=['scope_id', 'updated_at'], name='reactpy_dj_scope_i_1cf868_idx')], + }, + ), + migrations.AddConstraint( + model_name='sessionstatemodel', + constraint=models.UniqueConstraint(fields=('scope_id', 'key'), name='reactpy_django_sess_scope_id_key'), + ), + ] From 447427c5faa5849277dc43508e624530be2fb8f0 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:54:17 -0700 Subject: [PATCH 04/26] Add SessionState type --- src/reactpy_django/types.py | 132 +----------------------------------- 1 file changed, 3 insertions(+), 129 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index e71f9c63..f5cd2ed4 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -1,134 +1,8 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - NamedTuple, - Protocol, - TypeVar, - Union, -) - -from django.http import HttpRequest -from reactpy.types import Component, Connection, Key -from typing_extensions import ParamSpec - -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 - - -FuncParams = ParamSpec("FuncParams") -Inferred = TypeVar("Inferred") -ConnectionType = Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] - - -@dataclass -class Query(Generic[Inferred]): - """Queries generated by the `use_query` hook.""" - - data: Inferred - loading: bool - error: Exception | None - refetch: Callable[[], None] - - -@dataclass -class Mutation(Generic[FuncParams]): - """Mutations generated by the `use_mutation` hook.""" - - execute: Callable[FuncParams, None] - loading: bool - error: Exception | None - reset: Callable[[], None] - - def __call__(self, *args: FuncParams.args, **kwargs: FuncParams.kwargs) -> None: - """Execute the mutation.""" - self.execute(*args, **kwargs) - - -@dataclass -class FormEventData: - """State of a form provided to Form custom events.""" - - form: Form | ModelForm - submitted_data: dict[str, Any] - set_submitted_data: Callable[[dict[str, Any] | None], None] - - -class AsyncFormEvent(Protocol): - async def __call__(self, event: FormEventData) -> None: ... - - -class SyncFormEvent(Protocol): - def __call__(self, event: FormEventData) -> None: ... - - -class AsyncPostprocessor(Protocol): - async def __call__(self, data: Any) -> Any: ... - - -class SyncPostprocessor(Protocol): - def __call__(self, data: Any) -> Any: ... - - -@dataclass -class ComponentParams: - """Container used for serializing component parameters. - This dataclass is pickled & stored in the database, then unpickled when needed.""" - - args: Sequence - kwargs: MutableMapping[str, Any] - - class UserData(NamedTuple): query: Query[dict | None] mutation: Mutation[dict] -class AsyncMessageReceiver(Protocol): - async def __call__(self, message: dict) -> None: ... - - -class AsyncMessageSender(Protocol): - async def __call__(self, message: dict) -> None: ... - - -class ViewToComponentConstructor(Protocol): - def __call__( - self, request: HttpRequest | None = None, *args: Any, key: Key | None = None, **kwargs: Any - ) -> Component: ... - - -class ViewToIframeConstructor(Protocol): - def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> Component: ... - - -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.""" +class SessionState(NamedTuple): + query: Query[Any] + mutation: Mutation[Any] From a646707529be147b3673646f1f5731c34d57eeb8 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:54:50 -0700 Subject: [PATCH 05/26] Restore types.py and add SessionState type --- src/reactpy_django/types.py | 132 +++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index f5cd2ed4..e71f9c63 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -1,8 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Generic, + NamedTuple, + Protocol, + TypeVar, + Union, +) + +from django.http import HttpRequest +from reactpy.types import Component, Connection, Key +from typing_extensions import ParamSpec + +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 + + +FuncParams = ParamSpec("FuncParams") +Inferred = TypeVar("Inferred") +ConnectionType = Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] + + +@dataclass +class Query(Generic[Inferred]): + """Queries generated by the `use_query` hook.""" + + data: Inferred + loading: bool + error: Exception | None + refetch: Callable[[], None] + + +@dataclass +class Mutation(Generic[FuncParams]): + """Mutations generated by the `use_mutation` hook.""" + + execute: Callable[FuncParams, None] + loading: bool + error: Exception | None + reset: Callable[[], None] + + def __call__(self, *args: FuncParams.args, **kwargs: FuncParams.kwargs) -> None: + """Execute the mutation.""" + self.execute(*args, **kwargs) + + +@dataclass +class FormEventData: + """State of a form provided to Form custom events.""" + + form: Form | ModelForm + submitted_data: dict[str, Any] + set_submitted_data: Callable[[dict[str, Any] | None], None] + + +class AsyncFormEvent(Protocol): + async def __call__(self, event: FormEventData) -> None: ... + + +class SyncFormEvent(Protocol): + def __call__(self, event: FormEventData) -> None: ... + + +class AsyncPostprocessor(Protocol): + async def __call__(self, data: Any) -> Any: ... + + +class SyncPostprocessor(Protocol): + def __call__(self, data: Any) -> Any: ... + + +@dataclass +class ComponentParams: + """Container used for serializing component parameters. + This dataclass is pickled & stored in the database, then unpickled when needed.""" + + args: Sequence + kwargs: MutableMapping[str, Any] + + class UserData(NamedTuple): query: Query[dict | None] mutation: Mutation[dict] -class SessionState(NamedTuple): - query: Query[Any] - mutation: Mutation[Any] +class AsyncMessageReceiver(Protocol): + async def __call__(self, message: dict) -> None: ... + + +class AsyncMessageSender(Protocol): + async def __call__(self, message: dict) -> None: ... + + +class ViewToComponentConstructor(Protocol): + def __call__( + self, request: HttpRequest | None = None, *args: Any, key: Key | None = None, **kwargs: Any + ) -> Component: ... + + +class ViewToIframeConstructor(Protocol): + def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> Component: ... + + +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.""" From 3a72b7d5edba26036755872c1e1322854c985d40 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 15:55:24 -0700 Subject: [PATCH 06/26] Add SessionState type used by use_session_state --- src/reactpy_django/types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index e71f9c63..cfc70aed 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -93,6 +93,11 @@ class UserData(NamedTuple): mutation: Mutation[dict] +class SessionState(NamedTuple): + query: Query[Any] + mutation: Mutation[Any] + + class AsyncMessageReceiver(Protocol): async def __call__(self, message: dict) -> None: ... From ed4040b023719ed5bd21303d296ea5690a77d91f Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:01:19 -0700 Subject: [PATCH 07/26] Revert types.py to original --- src/reactpy_django/types.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index cfc70aed..e71f9c63 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -93,11 +93,6 @@ class UserData(NamedTuple): mutation: Mutation[dict] -class SessionState(NamedTuple): - query: Query[Any] - mutation: Mutation[Any] - - class AsyncMessageReceiver(Protocol): async def __call__(self, message: dict) -> None: ... From 962ce320c5f95ee45cb20fa971981b25b9e57848 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:03:50 -0700 Subject: [PATCH 08/26] Add use_session_state persistent state hook --- src/reactpy_django/hooks.py | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index f0f6120f..e9274aec 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -12,6 +12,7 @@ ) from uuid import uuid4 +import dill import orjson from channels import DEFAULT_CHANNEL_LAYER from channels import auth as channels_auth @@ -331,6 +332,111 @@ async def _set_user_data(data: dict): return UserData(query, mutation) +def use_session_state( + default: Any, + key: str, + *, + save_default: bool = False, +) -> tuple[Any, Callable[[Any], None]]: + """Persist state across WebSocket reconnects (and, optionally, page reloads). + + This hook stores its value in the ReactPy database so that it can be restored after a + WebSocket reconnect, which would otherwise reset all in-memory component state. It is + more robust than in-memory state because it survives multi-process deployments and + round-robin load balancing across multiple hosts. + + The state's scope is controlled by the ``REACTPY_SESSION_STATE_MODE`` setting: + + - ``"tab"`` (default): state is scoped to the rendered component (a per-tab, per-component + token that is stable across reconnects). Works for anonymous users without requiring + ``django.contrib.sessions``. + - ``"user"``: state is scoped to the authenticated user, falling back to a per-tab token + for anonymous users. + + Args: + default: The value to use when no persisted state exists. + key: A unique identifier for this state slot within the computed scope. Multiple + ``use_session_state`` hooks in the same component must use distinct keys. + + Kwargs: + save_default: If ``True``, the ``default`` value will be persisted when no state + already exists in the database. + + Returns: + A tuple of ``(state, set_state)``. ``state`` is the current value (loaded from the + database, or ``default`` if none exists). ``set_state`` updates the in-memory value + immediately and schedules a debounced database write so that frequently-changing + values do not hammer the database. + + Note: + Only serializable data may be stored. Values are serialized with ``dill``, so most + common Python objects are supported, but objects holding un-picklable resources + (e.g. open file handles or network connections) will fail. + """ + from reactpy_django import config + + scope_id = _resolve_session_state_scope_id() + + # In-memory reactive state. This keeps the UI responsive while database writes are + # debounced in the background. + state, set_state = use_state(cast("Any", default)) + loaded = use_ref(False) + latest = use_ref(default) + timer = use_ref(None) + + @use_async_effect(dependencies=[key, scope_id]) + async def load_state() -> None: + """Load the persisted value from the database once on mount.""" + data = await _get_session_state(scope_id, key, default) + latest.current = data + set_state(data) + loaded.current = True + if save_default and data == default: + await _set_session_state(scope_id, key, data) + + async def flush() -> None: + """Persist the latest value to the database.""" + await _set_session_state(scope_id, key, latest.current) + + async def debounced_flush() -> None: + """Wait for the sync interval and then persist the latest value.""" + try: + await asyncio.sleep(config.REACTPY_SESSION_STATE_SYNC_INTERVAL) + await flush() + finally: + timer.current = None + + def schedule_flush() -> None: + """Debounce database writes so rapid state changes coalesce into one write.""" + if timer.current is not None and not timer.current.done(): + timer.current.cancel() + timer.current = asyncio.create_task(debounced_flush()) + + @use_callback + def set_state_and_persist(value: Any) -> None: + """Update the in-memory state and schedule a debounced database write.""" + latest.current = value + set_state(value) + schedule_flush() + + @use_async_effect(dependencies=[]) + async def flush_on_unmount() -> Callable[[], None]: + """Ensure the latest state is persisted when the component unmounts. + + This is what makes state survive a WebSocket reconnect: the component's layout is + torn down on disconnect, so we flush any pending write before it is lost. + """ + + def _cleanup() -> None: + if timer.current is not None and not timer.current.done(): + timer.current.cancel() + asyncio.create_task(flush()) + + return _cleanup + + return state, set_state_and_persist + + def use_channel_layer( *, channel: str | None = None, @@ -441,6 +547,45 @@ async def logout(rerender: bool = True) -> None: return UseAuthTuple(login=login, logout=logout) +def _resolve_session_state_scope_id() -> str: + """Resolve the stable identity used to scope persistent session state. + + In ``"tab"`` mode this is the rendered component's UUID (stable across reconnects). + In ``"user"`` mode this is the authenticated user's primary key, falling back to a + per-tab token for anonymous users so that unauthenticated visitors still get isolated, + persistent state. + """ + from reactpy_django import config + + root_id = use_root_id() + + if config.REACTPY_SESSION_STATE_MODE == "user": + user = use_user() + if not user.is_anonymous: + return f"user:{get_pk(user)}" + + return f"tab:{root_id}" + + +async def _get_session_state(scope_id: str, key: str, default: Any) -> Any: + """Load a persisted session state value from the database, or return `default`.""" + from reactpy_django.models import SessionStateModel + + model = await SessionStateModel.objects.filter(scope_id=scope_id, key=key).afirst() + if model is None or model.data is None: + return default + return dill.loads(model.data) + + +async def _set_session_state(scope_id: str, key: str, data: Any) -> None: + """Persist a session state value to the database.""" + from reactpy_django.models import SessionStateModel + + model, _ = await SessionStateModel.objects.aget_or_create(scope_id=scope_id, key=key) + model.data = dill.dumps(data) + await model.asave() + + async def _get_user_data(user: AbstractUser, default_data: dict | None, save_default_data: bool) -> dict | None: """The mutation function for `use_user_data`""" from reactpy_django.models import UserDataModel From 742e67aafd160995c65d927c6ac7119d193b8f25 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:04:47 -0700 Subject: [PATCH 09/26] Add session state cleaning to clean task --- src/reactpy_django/tasks.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py index 44d4eb14..95326856 100644 --- a/src/reactpy_django/tasks.py +++ b/src/reactpy_django/tasks.py @@ -13,13 +13,14 @@ 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"] +CleaningArgs = Literal["all", "sessions", "auth_tokens", "user_data", "session_state"] def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1): from reactpy_django.config import ( REACTPY_CLEAN_AUTH_TOKENS, REACTPY_CLEAN_SESSIONS, + REACTPY_CLEAN_SESSION_STATE, REACTPY_CLEAN_USER_DATA, ) from reactpy_django.models import Config @@ -33,11 +34,13 @@ def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1): sessions = REACTPY_CLEAN_SESSIONS auth_tokens = REACTPY_CLEAN_AUTH_TOKENS user_data = REACTPY_CLEAN_USER_DATA + session_state = REACTPY_CLEAN_SESSION_STATE 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")) + session_state = any(value in args for value in ("session_state", "all")) if sessions: clean_component_sessions(verbosity) @@ -45,6 +48,8 @@ def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1): clean_auth_tokens(verbosity) if user_data: clean_user_data(verbosity) + if session_state: + clean_session_state(verbosity) def clean_component_sessions(verbosity: int = 1): @@ -124,6 +129,31 @@ def clean_user_data(verbosity: int = 1): inspect_clean_duration(start_time, "user data", verbosity) +def clean_session_state(verbosity: int = 1): + """Delete any expired ReactPy session state from the database. + + Session state entries that have not been updated within ``REACTPY_SESSION_STATE_MAX_AGE`` + are considered stale (e.g. the browser tab was closed) and are removed. + """ + from reactpy_django.config import DJANGO_DEBUG, REACTPY_SESSION_STATE_MAX_AGE + from reactpy_django.models import SessionStateModel + + if verbosity >= 2: + _logger.info("Cleaning ReactPy session state...") + + start_time = timezone.now() + expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_STATE_MAX_AGE) + state_objects = SessionStateModel.objects.filter(updated_at__lte=expiration_date) + + if verbosity >= 2: + _logger.info("Deleting %d expired session state objects...", state_objects.count()) + + state_objects.delete() + + if DJANGO_DEBUG or verbosity >= 2: + inspect_clean_duration(start_time, "session state", verbosity) + + 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 1fe926b459ac1d3b6c5e9c8796e55540109c90eb Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:05:20 -0700 Subject: [PATCH 10/26] Add --session-state flag to clean_reactpy command --- src/reactpy_django/management/commands/clean_reactpy.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py index 804d5a3e..4a6f77d0 100644 --- a/src/reactpy_django/management/commands/clean_reactpy.py +++ b/src/reactpy_django/management/commands/clean_reactpy.py @@ -12,7 +12,7 @@ def handle(self, *_args, **options): from reactpy_django.tasks import CleaningArgs, clean verbosity = options.pop("verbosity", 1) - valid_args: set[CleaningArgs] = {"all", "sessions", "auth_tokens", "user_data"} + valid_args: set[CleaningArgs] = {"all", "sessions", "auth_tokens", "user_data", "session_state"} 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) @@ -36,3 +36,8 @@ def add_arguments(self, parser): action="store_true", help="Clean authentication tokens. This value can be combined with other cleaning options.", ) + parser.add_argument( + "--session-state", + action="store_true", + help="Clean persistent session state. This value can be combined with other cleaning options.", + ) From 5f14fbcccdc8037a15fe7799d7b77a04ac3eeac4 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:07:41 -0700 Subject: [PATCH 11/26] Add session state database tests --- tests/test_app/tests/test_database.py | 41 ++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 78856a89..01f5d80c 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -7,7 +7,7 @@ from django.test import TransactionTestCase from reactpy_django import tasks -from reactpy_django.models import ComponentSession, UserDataModel +from reactpy_django.models import ComponentSession, SessionStateModel, UserDataModel from reactpy_django.types import ComponentParams @@ -58,6 +58,45 @@ def test_component_params(self): config.REACTPY_SESSION_MAX_AGE = initial_session_max_age config.REACTPY_CLEAN_USER_DATA = initial_clean_user_data + def test_session_state_roundtrip_and_cleanup(self): + from reactpy_django import config + from reactpy_django.hooks import _get_session_state, _set_session_state + + initial_clean_session_state = config.REACTPY_CLEAN_SESSION_STATE + initial_session_state_max_age = config.REACTPY_SESSION_STATE_MAX_AGE + config.REACTPY_CLEAN_SESSION_STATE = False + config.REACTPY_SESSION_STATE_MAX_AGE = 1 + + try: + scope_id = f"tab:{uuid4()}" + key = "my_state" + + # No state exists yet, so the default is returned + assert _get_session_state(scope_id, key, default="default") == "default" + + # Persist some state and read it back + state = {"count": 1, "items": [1, 2, 3]} + _set_session_state(scope_id, key, state) + assert SessionStateModel.objects.filter(scope_id=scope_id, key=key).count() == 1 + assert _get_session_state(scope_id, key, default="default") == state + + # Persisting the same scope+key updates the existing row (no duplicate) + _set_session_state(scope_id, key, {"count": 2}) + assert SessionStateModel.objects.filter(scope_id=scope_id, key=key).count() == 1 + assert _get_session_state(scope_id, key, default="default") == {"count": 2} + + # Untouched state is considered stale and gets cleaned up + fresh_scope = f"tab:{uuid4()}" + _set_session_state(fresh_scope, "other", "keep-me") + assert SessionStateModel.objects.count() == 2 + sleep(config.REACTPY_SESSION_STATE_MAX_AGE) + tasks.clean_session_state() + assert SessionStateModel.objects.count() == 1 + assert _get_session_state(fresh_scope, "other", default="default") == "keep-me" + finally: + config.REACTPY_CLEAN_SESSION_STATE = initial_clean_session_state + config.REACTPY_SESSION_STATE_MAX_AGE = initial_session_state_max_age + def _save_params_to_db(self, value: Any) -> ComponentParams: db = next(iter(self.databases)) param_data = ComponentParams((value,), {"test_value": value}) From 404ca18b145f20441054abaede13a13daf9a81c6 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:08:46 -0700 Subject: [PATCH 12/26] Use explicit constraint/index names in SessionStateModel --- src/reactpy_django/models.py | 81 ++---------------------------------- 1 file changed, 4 insertions(+), 77 deletions(-) diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 82b7921a..f59f8bea 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -1,70 +1,3 @@ -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. - - 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.""" - - cleaned_at = models.DateTimeField(auto_now_add=True) - - def save(self, *args, **kwargs): - """Singleton save method.""" - self.pk = 1 - super().save(*args, **kwargs) - - @classmethod - def load(cls): - obj, _ = cls.objects.get_or_create(pk=1) - return obj - - -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, 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) - data = models.BinaryField(null=True, blank=True) - - class SessionStateModel(models.Model): """A model for storing `session_state` data. @@ -82,15 +15,9 @@ class SessionStateModel(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - unique_together = ("scope_id", "key") + constraints = [ + models.UniqueConstraint(fields=["scope_id", "key"], name="reactpy_session_state_unique"), + ] indexes = [ - models.Index(fields=["scope_id", "updated_at"]), + models.Index(fields=["scope_id", "updated_at"], name="reactpy_session_state_idx"), ] - - -@receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data") -def delete_user_data(sender, instance, **kwargs): - """Delete ReactPy's `UserDataModel` when a Django `User` is deleted.""" - pk = get_pk(instance) - - UserDataModel.objects.filter(user_pk=pk).delete() From dde53dac70b8719bc1b85798cdf3f9f3cd1b0f0a Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:09:12 -0700 Subject: [PATCH 13/26] Restore models.py with explicit constraint names in SessionStateModel --- src/reactpy_django/models.py | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index f59f8bea..637a7be0 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -1,3 +1,70 @@ +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. + + 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.""" + + cleaned_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + """Singleton save method.""" + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def load(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + +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, 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) + data = models.BinaryField(null=True, blank=True) + + class SessionStateModel(models.Model): """A model for storing `session_state` data. @@ -21,3 +88,11 @@ class Meta: indexes = [ models.Index(fields=["scope_id", "updated_at"], name="reactpy_session_state_idx"), ] + + +@receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data") +def delete_user_data(sender, instance, **kwargs): + """Delete ReactPy's `UserDataModel` when a Django `User` is deleted.""" + pk = get_pk(instance) + + UserDataModel.objects.filter(user_pk=pk).delete() From 3dfe9d323ed444afaa191ef6460598e703a7a359 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:09:23 -0700 Subject: [PATCH 14/26] Match migration constraint/index names to model --- src/reactpy_django/migrations/0008_sessionstatemodel.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/reactpy_django/migrations/0008_sessionstatemodel.py b/src/reactpy_django/migrations/0008_sessionstatemodel.py index 8bb7976e..d58ac987 100644 --- a/src/reactpy_django/migrations/0008_sessionstatemodel.py +++ b/src/reactpy_django/migrations/0008_sessionstatemodel.py @@ -20,11 +20,8 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ], options={ - 'indexes': [models.Index(fields=['scope_id', 'updated_at'], name='reactpy_dj_scope_i_1cf868_idx')], + 'indexes': [models.Index(fields=['scope_id', 'updated_at'], name='reactpy_session_state_idx')], + 'constraints': [models.UniqueConstraint(fields=('scope_id', 'key'), name='reactpy_session_state_unique')], }, ), - migrations.AddConstraint( - model_name='sessionstatemodel', - constraint=models.UniqueConstraint(fields=('scope_id', 'key'), name='reactpy_django_sess_scope_id_key'), - ), ] From bd050cdc4c2408653c2e115e30861978041c24b9 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 14 Sep 2026 16:10:37 -0700 Subject: [PATCH 15/26] Add persistent session state feature to changelog --- CHANGELOG.md | 620 +-------------------------------------------------- 1 file changed, 6 insertions(+), 614 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526d721..72bb2f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,619 +1,11 @@ -# Changelog - -All notable changes to this project will be documented in this file. - - - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - - - - - - ## [Unreleased] ### Added +- Add `reactpy_django.hooks.use_session_state` hook for persistent state across WebSocket reconnects. + - State is stored in the ReactPy database, so it survives multi-process deployments and round-robin load balancing across hosts. + - `settings.py:REACTPY_SESSION_STATE_MODE` to control whether state is scoped per-tab (default) or per-user. + - `settings.py:REACTPY_SESSION_STATE_SYNC_INTERVAL` to control how frequently state is flushed to the database. + - `settings.py:REACTPY_SESSION_STATE_MAX_AGE` to control how long stale session state is retained. + - `settings.py:REACTPY_CLEAN_SESSION_STATE` to control whether stale session state is cleaned up during automatic cleanups. - Automatically serve ReactPy wheel from Django's static directory when using PyScript. - -### Changed - -- Use one WebSocket per client webpage. -- Updated dependencies: `reactpy>=2.0.0, <3.0.0` and `reactpy-router>=3.0.0, <4.0.0`. -- Updated Python support to 3.11–3.14. -- Replaced PyScript `` tags with standard `