Skip to content

Commit 27c4c57

Browse files
authored
Add use_session_state hook for persistent state across WebSocket reconnects (#300)
Add a dedicated, database-backed hook `reactpy_django.hooks.use_session_state(default, key)` that persists state so it survives WebSocket reconnects (and, optionally, page reloads).
1 parent df90f52 commit 27c4c57

13 files changed

Lines changed: 505 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ Don't forget to remove deprecated code on each major release!
2121

2222
### Added
2323

24+
- Add `reactpy_django.hooks.use_session_state` hook for persistent state across WebSocket reconnects.
25+
- State is stored in the ReactPy database, so it survives multi-process deployments and round-robin load balancing across hosts.
26+
- `settings.py:REACTPY_SESSION_STATE_MODE` to control whether state is scoped per-tab (default) or per-user.
27+
- `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.
28+
- `settings.py:REACTPY_SESSION_STATE_MAX_AGE` to control how long stale session state is retained.
29+
- `settings.py:REACTPY_CLEAN_SESSION_STATE` to control whether stale session state is cleaned up during automatic cleanups.
2430
- Automatically serve ReactPy wheel from Django's static directory when using PyScript.
2531

2632
### Changed
@@ -444,7 +450,7 @@ Don't forget to remove deprecated code on each major release!
444450
### Fixed
445451
446452
- Change type hint on `view_to_component` callable to have `request` argument be optional.
447-
- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`)
453+
- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`).
448454
449455
### Security
450456
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from reactpy import component, html
2+
3+
from reactpy_django.hooks import use_session_state
4+
5+
6+
@component
7+
def my_component():
8+
count, set_count = use_session_state(0, key="counter")
9+
10+
return html.button(
11+
{"onClick": lambda _: set_count(count + 1)},
12+
f"Count: {count}",
13+
)

‎docs/src/dictionary.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ backends
55
backhaul
66
broadcasted
77
changelog
8+
debounced
89
django
910
frontend
1011
frontends
@@ -18,6 +19,7 @@ misconfiguration
1819
misconfigurations
1920
my_template
2021
nox
22+
picklable
2123
plotly
2224
postfixed
2325
postprocessing
@@ -39,6 +41,7 @@ serializable
3941
stylesheet
4042
stylesheets
4143
sublicense
44+
unmount
4245
unstyled
4346
WebSocket
4447
WebSockets

‎docs/src/reference/hooks.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,47 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`.
385385

386386
---
387387

388+
### Use Session State
389+
390+
Persist state across WebSocket reconnects (and, optionally, page reloads) so that it survives a fresh `#!python Layout` being created on reconnect.
391+
392+
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.
393+
394+
=== "components.py"
395+
396+
```python
397+
{% include "../../examples/python/use_session_state.py" %}
398+
```
399+
400+
??? example "See Interface"
401+
402+
<font size="4">**Parameters**</font>
403+
404+
| Name | Type | Description | Default |
405+
| --- | --- | --- | --- |
406+
| `#!python default` | `#!python Any` | The value to use when no persisted state exists. | N/A |
407+
| `#!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 |
408+
| `#!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` |
409+
410+
<font size="4">**Returns**</font>
411+
412+
| Type | Description |
413+
| --- | --- |
414+
| `#!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. |
415+
416+
??? question "How is state scoped?"
417+
418+
The state's scope is controlled by the `#!python REACTPY_SESSION_STATE_MODE` [setting](./settings.md#reactpy_session_state_mode).
419+
420+
- `#!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.
421+
- `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users.
422+
423+
??? warning "Only serializable data may be stored"
424+
425+
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.
426+
427+
---
428+
388429
## Communication Hooks
389430

390431
---

‎docs/src/reference/settings.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,47 @@ You can use the `#!python prerender` argument in your [template tag](./template-
194194

195195
---
196196

197+
## Session State Settings
198+
199+
---
200+
201+
### `#!python REACTPY_SESSION_STATE_MODE`
202+
203+
**Default:** `#!python "tab"`
204+
205+
**Example Value(s):** `#!python "tab"`, `#!python "user"`
206+
207+
Controls the scope of state persisted by the [`use_session_state`](./hooks.md#use-session-state) hook.
208+
209+
- `#!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.
210+
- `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users.
211+
212+
---
213+
214+
### `#!python REACTPY_SESSION_STATE_SYNC_INTERVAL`
215+
216+
**Default:** `#!python 10`
217+
218+
**Example Value(s):** `#!python 0`, `#!python 1`, `#!python 30`, `#!python 60`
219+
220+
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.
221+
222+
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).
223+
224+
---
225+
226+
### `#!python REACTPY_SESSION_STATE_MAX_AGE`
227+
228+
**Default:** `#!python 259200`
229+
230+
**Example Value(s):** `#!python 0`, `#!python 3600`, `#!python 604800`
231+
232+
Maximum seconds stale session state is retained before it is removed during [ReactPy clean up](#auto-clean-settings).
233+
234+
Use `#!python 0` to immediately expire stale session state.
235+
236+
---
237+
197238
## Stability Settings
198239

199240
---
@@ -303,3 +344,15 @@ Configures whether ReactPy should clean up expired authentication tokens during
303344
Configures whether ReactPy should clean up orphaned user data during automatic clean up operations.
304345

305346
Typically, user data does not become orphaned unless the server crashes during a `#!python User` delete operation.
347+
348+
---
349+
350+
### `#!python REACTPY_CLEAN_SESSION_STATE`
351+
352+
**Default:** `#!python True`
353+
354+
**Example Value(s):** `#!python False`
355+
356+
Configures whether ReactPy should clean up stale session state during automatic clean up operations.
357+
358+
Stale session state is state that has not been updated within `#!python REACTPY_SESSION_STATE_MAX_AGE` seconds.

‎src/reactpy_django/checks.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,4 +564,75 @@ def reactpy_errors(app_configs, **kwargs):
564564
)
565565
)
566566

567+
# Check if REACTPY_SESSION_STATE_MODE is a valid data type
568+
if not isinstance(config.REACTPY_SESSION_STATE_MODE, str):
569+
errors.append(
570+
checks.Error(
571+
"Invalid type for REACTPY_SESSION_STATE_MODE.",
572+
hint="REACTPY_SESSION_STATE_MODE should be a string.",
573+
id="reactpy_django.E030",
574+
)
575+
)
576+
577+
# Check if REACTPY_SESSION_STATE_MODE is a valid value
578+
if config.REACTPY_SESSION_STATE_MODE not in ("tab", "user"):
579+
errors.append(
580+
checks.Error(
581+
"Invalid value for REACTPY_SESSION_STATE_MODE.",
582+
hint="REACTPY_SESSION_STATE_MODE should be either 'tab' or 'user'.",
583+
obj=config.REACTPY_SESSION_STATE_MODE,
584+
id="reactpy_django.E031",
585+
)
586+
)
587+
588+
# Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a valid data type
589+
if not isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int):
590+
errors.append(
591+
checks.Error(
592+
"Invalid type for REACTPY_SESSION_STATE_SYNC_INTERVAL.",
593+
hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be an integer.",
594+
id="reactpy_django.E032",
595+
)
596+
)
597+
598+
# Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a non-negative integer
599+
if isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int) and config.REACTPY_SESSION_STATE_SYNC_INTERVAL < 0:
600+
errors.append(
601+
checks.Error(
602+
"Invalid value for REACTPY_SESSION_STATE_SYNC_INTERVAL.",
603+
hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be a non-negative integer. Use 0 to disable periodic syncing.",
604+
id="reactpy_django.E033",
605+
)
606+
)
607+
608+
# Check if REACTPY_SESSION_STATE_MAX_AGE is a valid data type
609+
if not isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int):
610+
errors.append(
611+
checks.Error(
612+
"Invalid type for REACTPY_SESSION_STATE_MAX_AGE.",
613+
hint="REACTPY_SESSION_STATE_MAX_AGE should be an integer.",
614+
id="reactpy_django.E034",
615+
)
616+
)
617+
618+
# Check if REACTPY_SESSION_STATE_MAX_AGE is a positive integer
619+
if isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int) and config.REACTPY_SESSION_STATE_MAX_AGE < 0:
620+
errors.append(
621+
checks.Error(
622+
"Invalid value for REACTPY_SESSION_STATE_MAX_AGE.",
623+
hint="REACTPY_SESSION_STATE_MAX_AGE should be a positive integer.",
624+
id="reactpy_django.E035",
625+
)
626+
)
627+
628+
# Check if REACTPY_CLEAN_SESSION_STATE is a valid data type
629+
if not isinstance(config.REACTPY_CLEAN_SESSION_STATE, bool):
630+
errors.append(
631+
checks.Error(
632+
"Invalid type for REACTPY_CLEAN_SESSION_STATE.",
633+
hint="REACTPY_CLEAN_SESSION_STATE should be a boolean.",
634+
id="reactpy_django.E036",
635+
)
636+
)
637+
567638
return errors

‎src/reactpy_django/config.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from itertools import cycle
4-
from typing import TYPE_CHECKING, Callable
4+
from typing import TYPE_CHECKING, Callable, Literal
55

66
from django.conf import settings
77
from django.core.cache import DEFAULT_CACHE_ALIAS
@@ -136,6 +136,27 @@
136136
"REACTPY_CLEAN_USER_DATA",
137137
True,
138138
)
139+
REACTPY_CLEAN_SESSION_STATE: bool = getattr(
140+
settings,
141+
"REACTPY_CLEAN_SESSION_STATE",
142+
True,
143+
)
144+
SessionStateMode = Literal["tab", "user"]
145+
REACTPY_SESSION_STATE_MODE: SessionStateMode = getattr(
146+
settings,
147+
"REACTPY_SESSION_STATE_MODE",
148+
"tab", # Default to per-tab (per-component token) scope
149+
)
150+
REACTPY_SESSION_STATE_SYNC_INTERVAL: int = getattr(
151+
settings,
152+
"REACTPY_SESSION_STATE_SYNC_INTERVAL",
153+
10, # Default to 10 seconds; set to 0 to disable periodic syncing
154+
)
155+
REACTPY_SESSION_STATE_MAX_AGE: int = getattr(
156+
settings,
157+
"REACTPY_SESSION_STATE_MAX_AGE",
158+
259200, # Default to 3 days
159+
)
139160
REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr(
140161
settings,
141162
"REACTPY_DEFAULT_FORM_TEMPLATE",

0 commit comments

Comments
 (0)