diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526d721..0ca6c6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ Don't forget to remove deprecated code on each major release! ### 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` (default 10 seconds) to control how frequently state is flushed to the database. Set to `0` to disable periodic syncing so state is only persisted on unmount. + - `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 @@ -444,7 +450,7 @@ Don't forget to remove deprecated code on each major release! ### Fixed - Change type hint on `view_to_component` callable to have `request` argument be optional. -- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`) +- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`). ### Security diff --git a/docs/examples/python/use_session_state.py b/docs/examples/python/use_session_state.py new file mode 100644 index 00000000..19f67994 --- /dev/null +++ b/docs/examples/python/use_session_state.py @@ -0,0 +1,13 @@ +from reactpy import component, html + +from reactpy_django.hooks import use_session_state + + +@component +def my_component(): + count, set_count = use_session_state(0, key="counter") + + return html.button( + {"onClick": lambda _: set_count(count + 1)}, + f"Count: {count}", + ) diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index d2ff722d..e4025d3a 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -5,6 +5,7 @@ backends backhaul broadcasted changelog +debounced django frontend frontends @@ -18,6 +19,7 @@ misconfiguration misconfigurations my_template nox +picklable plotly postfixed postprocessing @@ -39,6 +41,7 @@ serializable stylesheet stylesheets sublicense +unmount unstyled WebSocket WebSockets diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index e8c3d8fb..20cad76d 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -385,6 +385,47 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. --- +### Use Session State + +Persist state across WebSocket reconnects (and, optionally, page reloads) so that it survives a fresh `#!python Layout` being created on reconnect. + +This hook stores its value in the `#!python REACTPY_DATABASE`, so it is more robust than in-memory state because it survives multi-process deployments and round-robin load balancing across multiple hosts. + +=== "components.py" + + ```python + {% include "../../examples/python/use_session_state.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python default` | `#!python Any` | The value to use when no persisted state exists. | N/A | + | `#!python key` | `#!python str` | A unique identifier for this state slot within the computed scope. Multiple `#!python use_session_state` hooks in the same component must use distinct keys. | N/A | + | `#!python save_default` | `#!python bool` | If `#!python True`, the `#!python default` value will be persisted when no state already exists in the database. | `#!python False` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python tuple[Any, Callable[[Any], None]]` | A tuple of `#!python (state, set_state)`. `#!python state` is the current value (loaded from the database, or `#!python default` if none exists). `#!python set_state` updates the in-memory value immediately and schedules a debounced database write so that frequently-changing values do not hammer the database. The update interval is controlled by `#!python REACTPY_SESSION_STATE_SYNC_INTERVAL`; setting it to `#!python 0` disables periodic syncing so writes only occur on unmount. | + +??? question "How is state scoped?" + + The state's scope is controlled by the `#!python REACTPY_SESSION_STATE_MODE` [setting](./settings.md#reactpy_session_state_mode). + + - `#!python "tab"` (default): state is scoped to the rendered component (a per-tab, per-component token that is stable across reconnects). This works for anonymous users without requiring `#!python django.contrib.sessions`, and isolates state between browser tabs. + - `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users. + +??? warning "Only serializable data may be stored" + + Values are serialized with `#!python dill`, so most common Python objects are supported, but objects holding resources that are not picklable (e.g. open file handles or network connections) will fail. + +--- + ## Communication Hooks --- diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 50d0b7db..cee473a4 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -194,6 +194,47 @@ You can use the `#!python prerender` argument in your [template tag](./template- --- +## Session State Settings + +--- + +### `#!python REACTPY_SESSION_STATE_MODE` + +**Default:** `#!python "tab"` + +**Example Value(s):** `#!python "tab"`, `#!python "user"` + +Controls the scope of state persisted by the [`use_session_state`](./hooks.md#use-session-state) hook. + +- `#!python "tab"` (default): state is scoped to the rendered component (a per-tab, per-component token that is stable across WebSocket reconnects). This works for anonymous users without requiring `#!python django.contrib.sessions`, and isolates state between browser tabs. +- `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users. + +--- + +### `#!python REACTPY_SESSION_STATE_SYNC_INTERVAL` + +**Default:** `#!python 10` + +**Example Value(s):** `#!python 0`, `#!python 1`, `#!python 30`, `#!python 60` + +Seconds between debounced database flush writes for `#!python use_session_state`. Rapid state changes (e.g. typing) are coalesced into a single database write after this interval elapses. + +Set this value to `#!python 0` to disable periodic syncing. In that case, state is only persisted to the database when the component is unmounted (such as when a WebSocket reconnects). + +--- + +### `#!python REACTPY_SESSION_STATE_MAX_AGE` + +**Default:** `#!python 259200` + +**Example Value(s):** `#!python 0`, `#!python 3600`, `#!python 604800` + +Maximum seconds stale session state is retained before it is removed during [ReactPy clean up](#auto-clean-settings). + +Use `#!python 0` to immediately expire stale session state. + +--- + ## Stability Settings --- @@ -303,3 +344,15 @@ Configures whether ReactPy should clean up expired authentication tokens during Configures whether ReactPy should clean up orphaned user data during automatic clean up operations. Typically, user data does not become orphaned unless the server crashes during a `#!python User` delete operation. + +--- + +### `#!python REACTPY_CLEAN_SESSION_STATE` + +**Default:** `#!python True` + +**Example Value(s):** `#!python False` + +Configures whether ReactPy should clean up stale session state during automatic clean up operations. + +Stale session state is state that has not been updated within `#!python REACTPY_SESSION_STATE_MAX_AGE` seconds. diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index d778e531..380daffc 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -564,4 +564,75 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_SESSION_STATE_MODE is a valid data type + if not isinstance(config.REACTPY_SESSION_STATE_MODE, str): + errors.append( + checks.Error( + "Invalid type for REACTPY_SESSION_STATE_MODE.", + hint="REACTPY_SESSION_STATE_MODE should be a string.", + id="reactpy_django.E030", + ) + ) + + # Check if REACTPY_SESSION_STATE_MODE is a valid value + if config.REACTPY_SESSION_STATE_MODE not in ("tab", "user"): + errors.append( + checks.Error( + "Invalid value for REACTPY_SESSION_STATE_MODE.", + hint="REACTPY_SESSION_STATE_MODE should be either 'tab' or 'user'.", + obj=config.REACTPY_SESSION_STATE_MODE, + id="reactpy_django.E031", + ) + ) + + # Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a valid data type + if not isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int): + errors.append( + checks.Error( + "Invalid type for REACTPY_SESSION_STATE_SYNC_INTERVAL.", + hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be an integer.", + id="reactpy_django.E032", + ) + ) + + # Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a non-negative integer + if isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int) and config.REACTPY_SESSION_STATE_SYNC_INTERVAL < 0: + errors.append( + checks.Error( + "Invalid value for REACTPY_SESSION_STATE_SYNC_INTERVAL.", + hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be a non-negative integer. Use 0 to disable periodic syncing.", + id="reactpy_django.E033", + ) + ) + + # Check if REACTPY_SESSION_STATE_MAX_AGE is a valid data type + if not isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int): + errors.append( + checks.Error( + "Invalid type for REACTPY_SESSION_STATE_MAX_AGE.", + hint="REACTPY_SESSION_STATE_MAX_AGE should be an integer.", + id="reactpy_django.E034", + ) + ) + + # Check if REACTPY_SESSION_STATE_MAX_AGE is a positive integer + if isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int) and config.REACTPY_SESSION_STATE_MAX_AGE < 0: + errors.append( + checks.Error( + "Invalid value for REACTPY_SESSION_STATE_MAX_AGE.", + hint="REACTPY_SESSION_STATE_MAX_AGE should be a positive integer.", + id="reactpy_django.E035", + ) + ) + + # Check if REACTPY_CLEAN_SESSION_STATE is a valid data type + if not isinstance(config.REACTPY_CLEAN_SESSION_STATE, bool): + errors.append( + checks.Error( + "Invalid type for REACTPY_CLEAN_SESSION_STATE.", + hint="REACTPY_CLEAN_SESSION_STATE should be a boolean.", + id="reactpy_django.E036", + ) + ) + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 0b71ec17..10644fd9 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", + 10, # Default to 10 seconds; set to 0 to disable periodic syncing +) +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", diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index f0f6120f..4d0e0c8a 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,127 @@ 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) + # True once the user has written a value; prevents the async DB load from clobbering + # a concurrent user update. + user_set = use_ref(False) + # Latest value written by the user (kept in a ref so cleanup always flushes it). + latest = use_ref(default) + # The currently scheduled debounced flush task; retained to prevent GC. + flush_task = use_ref(cast("asyncio.Task[None] | None", None)) + # Keep the current scope/key in refs so the unmount cleanup (which captures the first + # render's closure) always flushes to the correct scope/key. + scope_id_ref = use_ref(scope_id) + key_ref = use_ref(key) + + @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_ref.current, key_ref.current, default) + if not user_set.current: + latest.current = data + set_state(data) + loaded.current = True + if save_default and not user_set.current and data == default: + await _set_session_state(scope_id_ref.current, key_ref.current, data) + + async def flush() -> None: + """Persist the latest value to the database.""" + await _set_session_state(scope_id_ref.current, key_ref.current, 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: + if flush_task.current is asyncio.current_task(): + flush_task.current = None + + def schedule_flush() -> None: + """Debounce database writes so rapid state changes coalesce into one write.""" + # A sync interval of `0` disables periodic syncing. In that case, only the + # unmount cleanup will persist the latest value to the database. + if config.REACTPY_SESSION_STATE_SYNC_INTERVAL == 0: + return + if flush_task.current is not None and not flush_task.current.done(): + flush_task.current.cancel() + flush_task.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.""" + user_set.current = True + 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 flush_task.current is not None and not flush_task.current.done(): + flush_task.current.cancel() + flush_task.current = asyncio.create_task(flush()) + + return _cleanup + + return state, set_state_and_persist + + def use_channel_layer( *, channel: str | None = None, @@ -441,6 +563,46 @@ 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": + connection = use_connection() + user = connection.scope.get("user") + if user is not None and 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 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.", + ) diff --git a/src/reactpy_django/migrations/0008_sessionstatemodel.py b/src/reactpy_django/migrations/0008_sessionstatemodel.py new file mode 100644 index 00000000..d58ac987 --- /dev/null +++ b/src/reactpy_django/migrations/0008_sessionstatemodel.py @@ -0,0 +1,27 @@ +# 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_session_state_idx')], + 'constraints': [models.UniqueConstraint(fields=('scope_id', 'key'), name='reactpy_session_state_unique')], + }, + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index ab143736..fd264f4b 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -1,3 +1,4 @@ +# ruff: noqa: RUF012 from datetime import timedelta from django.contrib.auth import get_user_model @@ -65,6 +66,31 @@ 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: + constraints = [ + models.UniqueConstraint(fields=["scope_id", "key"], name="reactpy_session_state_unique"), + ] + 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.""" 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.""" diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 78856a89..0c6183b1 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -1,4 +1,5 @@ # ruff: noqa: RUF012 +import asyncio from time import sleep from typing import Any from uuid import uuid4 @@ -7,7 +8,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 +59,47 @@ 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 asyncio.run(_get_session_state(scope_id, key, default="default")) == "default" + + # Persist some state and read it back + state = {"count": 1, "items": [1, 2, 3]} + asyncio.run(_set_session_state(scope_id, key, state)) + assert SessionStateModel.objects.filter(scope_id=scope_id, key=key).count() == 1 + assert asyncio.run(_get_session_state(scope_id, key, default="default")) == state + + # Persisting the same scope+key updates the existing row (no duplicate) + asyncio.run(_set_session_state(scope_id, key, {"count": 2})) + assert SessionStateModel.objects.filter(scope_id=scope_id, key=key).count() == 1 + assert asyncio.run(_get_session_state(scope_id, key, default="default")) == {"count": 2} + + # Let the first state row age past the expiry threshold + sleep(config.REACTPY_SESSION_STATE_MAX_AGE) + + # A freshly-written (non-expired) entry should survive cleaning + fresh_scope = f"tab:{uuid4()}" + asyncio.run(_set_session_state(fresh_scope, "other", "keep-me")) + assert SessionStateModel.objects.count() == 2 + tasks.clean_session_state() + assert SessionStateModel.objects.count() == 1 + assert asyncio.run(_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})