Skip to content

Commit 25568e3

Browse files
authored
Fix client-storage writes during boot hydration (#7460)
* Preserve client-storage semantics during boot hydration * Use normal hydration deltas for snapshot corrections * Split hydration release notes into bugfix and feature * Add bugfix documentation for computed var changes
1 parent a3ce002 commit 25568e3

5 files changed

Lines changed: 308 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Prevent hydration from persisting client-storage defaults.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Deliver changes to client-storage and other state vars made by computed vars during hydration to the browser.

‎reflex/state.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2338,6 +2338,7 @@ async def hydrate_and_load(
23382338
self._reset_client_storage()
23392339
if vars:
23402340
await _apply_client_storage_vars(self, vars)
2341+
self._clean()
23412342
# The snapshot must carry is_hydrated=False: the frontend skips
23422343
# writing client storage for a delta that is not yet hydrated, and
23432344
# the reset defaults above must not be written back to the browser.
@@ -2347,8 +2348,12 @@ async def hydrate_and_load(
23472348
delta = await _resolve_delta(self.dict())
23482349
if hashes:
23492350
delta = await _diff_against_initial_state(type(self), delta, hashes)
2351+
# Include the guard and values changed while resolving the snapshot.
2352+
for state_name, changes in (await self._get_resolved_delta()).items():
2353+
delta.setdefault(state_name, {}).update(changes)
23502354
await ctx.emit_delta(delta=delta)
2351-
self._clean()
2355+
# Follow-up corrections must be allowed to write browser storage.
2356+
self.dirty_vars.discard(constants.CompileVars.IS_HYDRATED)
23522357
if not RegistrationContext.get().app.get_load_events(self.rx_router_url.path):
23532358
self.is_hydrated = True
23542359
return None
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Browser regressions for client-storage writes during hydration."""
2+
3+
from collections.abc import Generator
4+
5+
import pytest
6+
from playwright.sync_api import Page, expect
7+
8+
from reflex.testing import AppHarness, AppHarnessProd
9+
10+
11+
def HydrationStorageApp():
12+
"""Create an app with mismatched defaults and hydration-time storage writes."""
13+
import uuid
14+
15+
import reflex as rx
16+
17+
class StorageState(rx.State):
18+
local: str = rx.LocalStorage("local-default", name="hydrate-local")
19+
session: str = rx.SessionStorage("session-default", name="hydrate-session")
20+
cookie: str = rx.Cookie("cookie-default", name="hydrate-cookie")
21+
nonce: rx.Field[str] = rx.field(default_factory=lambda: uuid.uuid4().hex)
22+
23+
@rx.var
24+
def checked(self) -> str:
25+
"""Clear rejected values in each storage type.
26+
27+
Returns:
28+
The current values for display.
29+
"""
30+
if self.local == "bad":
31+
self.local = ""
32+
if self.session == "bad":
33+
self.session = ""
34+
if self.cookie == "bad":
35+
self.cookie = ""
36+
return f"{self.local}|{self.session}|{self.cookie}"
37+
38+
@rx.event
39+
def load(self):
40+
"""Provide an on-load event for the second page."""
41+
42+
def index():
43+
"""Display hydration and normalized storage values.
44+
45+
Returns:
46+
The page component.
47+
"""
48+
return rx.box(
49+
rx.text(StorageState.checked, id="checked"),
50+
rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"),
51+
)
52+
53+
app = rx.App()
54+
app.add_page(index)
55+
app.add_page(index, route="/loaded", on_load=StorageState.load)
56+
57+
58+
@pytest.fixture(scope="module", params=[AppHarness, AppHarnessProd])
59+
def hydration_storage_app(
60+
request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory
61+
) -> Generator[AppHarness, None, None]:
62+
"""Run the same storage app in development and production.
63+
64+
Args:
65+
request: The harness parameter.
66+
tmp_path_factory: The temporary directory factory.
67+
68+
Yields:
69+
The running app harness.
70+
"""
71+
with request.param.create(
72+
root=tmp_path_factory.mktemp("hydration_storage"),
73+
app_source=HydrationStorageApp,
74+
) as harness:
75+
yield harness
76+
77+
78+
@pytest.mark.parametrize("route", ["", "loaded"])
79+
def test_hydration_storage(hydration_storage_app: AppHarness, page: Page, route: str):
80+
"""Defaults stay absent, and computed-var corrections persist after reload.
81+
82+
Args:
83+
hydration_storage_app: The running app.
84+
page: A fresh browser page.
85+
route: The page with or without on-load handlers.
86+
"""
87+
assert hydration_storage_app.frontend_url is not None
88+
page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/{route}")
89+
expect(page.locator("#hydrated")).to_have_text("true")
90+
expect(page.locator("#checked")).to_have_text(
91+
"local-default|session-default|cookie-default"
92+
)
93+
assert page.evaluate("localStorage.getItem('hydrate-local')") is None
94+
assert page.evaluate("sessionStorage.getItem('hydrate-session')") is None
95+
assert not any(
96+
cookie.get("name") == "hydrate-cookie" for cookie in page.context.cookies()
97+
)
98+
99+
page.evaluate("""() => {
100+
localStorage.setItem('hydrate-local', 'bad');
101+
sessionStorage.setItem('hydrate-session', 'bad');
102+
document.cookie = 'hydrate-cookie=bad; path=/';
103+
}""")
104+
page.reload()
105+
expect(page.locator("#hydrated")).to_have_text("true")
106+
expect(page.locator("#checked")).to_have_text("||")
107+
page.wait_for_function("""() =>
108+
localStorage.getItem('hydrate-local') === '' &&
109+
sessionStorage.getItem('hydrate-session') === '' &&
110+
document.cookie.split('; ').includes('hydrate-cookie=')
111+
""")
112+
page.reload()
113+
expect(page.locator("#hydrated")).to_have_text("true")
114+
expect(page.locator("#checked")).to_have_text("||")

‎tests/units/reflex_base/event/processor/test_base_state_processor.py‎

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,21 @@
2323
import reflex as rx
2424
from reflex import event
2525
from reflex.app import App
26+
from reflex.compiler.utils import compile_state
2627
from reflex.event import Event, EventSpec
2728
from reflex.istate.manager import StateManager
2829
from reflex.istate.manager.disk import StateManagerDisk
2930
from reflex.istate.manager.memory import StateManagerMemory
3031
from reflex.istate.manager.redis import StateManagerRedis
3132
from reflex.istate.manager.token import BaseStateToken
3233
from reflex.middleware.middleware import Middleware
33-
from reflex.state import BaseState, OnLoadInternalState, State, StateUpdate
34+
from reflex.state import (
35+
BaseState,
36+
OnLoadInternalState,
37+
State,
38+
StateUpdate,
39+
state_snapshot_hashes,
40+
)
3441
from reflex.utils import types as reflex_types
3542
from tests.units.conftest import metric_points
3643
from tests.units.mock_redis import mock_redis
@@ -1630,8 +1637,6 @@ async def test_hydrate_and_load_diffs_against_compiled_defaults(
16301637
emitted_deltas: List to capture emitted deltas.
16311638
token: The client token.
16321639
"""
1633-
from reflex.compiler.utils import compile_state
1634-
from reflex.state import state_snapshot_hashes
16351640

16361641
class CookieState(State):
16371642
flavor: str = rx.Cookie("plain")
@@ -1669,7 +1674,12 @@ def set_python_equal_values(self):
16691674
assert set(snapshot) == {state_name, CookieState.get_full_name()}
16701675
assert set(snapshot[state_name]) == {
16711676
var + FIELD_MARKER
1672-
for var in ("rx_router_page", "rx_router_url", "rx_router_route_id")
1677+
for var in (
1678+
"rx_router_page",
1679+
"rx_router_url",
1680+
"rx_router_route_id",
1681+
CompileVars.IS_HYDRATED,
1682+
)
16731683
}
16741684
assert snapshot[CookieState.get_full_name()] == {
16751685
"flavor" + FIELD_MARKER: "chocolate"
@@ -1718,6 +1728,178 @@ def set_python_equal_values(self):
17181728
assert "loads" + FIELD_MARKER in snapshot[CookieState.get_full_name()]
17191729

17201730

1731+
async def test_hydrate_keeps_storage_write_guard_for_mismatched_substate(
1732+
wired_app: App,
1733+
real_base_state_processor: BaseStateEventProcessor,
1734+
emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
1735+
token: str,
1736+
):
1737+
"""A full substate snapshot must not persist its client-storage defaults.
1738+
1739+
Args:
1740+
wired_app: The app wired to the state manager.
1741+
real_base_state_processor: The event processor.
1742+
emitted_deltas: Captured deltas.
1743+
token: The client token.
1744+
"""
1745+
1746+
class StorageState(State):
1747+
local: str = rx.LocalStorage("local-default")
1748+
session: str = rx.SessionStorage("session-default")
1749+
cookie: str = rx.Cookie("cookie-default")
1750+
1751+
wired_app.add_page(lambda: rx.text(StorageState.local), route="/")
1752+
wired_app._compile_page("index")
1753+
compiled = compile_state(State)
1754+
hashes = state_snapshot_hashes(compiled)
1755+
hashes[sorted(compiled).index(StorageState.get_full_name()) + 1] = "mismatch"
1756+
boot_name = Event.from_event_type(State.hydrate_and_load())[0].name # pyright: ignore[reportCallIssue]
1757+
1758+
async with real_base_state_processor as processor:
1759+
await (
1760+
await processor.enqueue(token, _boot_event(boot_name, {"hashes": hashes}))
1761+
).wait_all()
1762+
1763+
snapshot = emitted_deltas[0][1]
1764+
assert (
1765+
snapshot[State.get_full_name()][CompileVars.IS_HYDRATED + FIELD_MARKER] is False
1766+
)
1767+
assert snapshot[StorageState.get_full_name()] == {
1768+
"local" + FIELD_MARKER: "local-default",
1769+
"session" + FIELD_MARKER: "session-default",
1770+
"cookie" + FIELD_MARKER: "cookie-default",
1771+
}
1772+
1773+
1774+
@pytest.mark.parametrize("storage", [rx.LocalStorage, rx.SessionStorage, rx.Cookie])
1775+
@pytest.mark.parametrize("cached", [True, False])
1776+
@pytest.mark.parametrize("asynchronous", [True, False])
1777+
@pytest.mark.parametrize("with_load", [True, False])
1778+
@pytest.mark.parametrize("with_hashes", [True, False])
1779+
@pytest.mark.parametrize(
1780+
"processor_state_manager", ["in_process", "redis"], indirect=True
1781+
)
1782+
async def test_hydrate_delivers_computed_var_mutations(
1783+
wired_app: App,
1784+
real_base_state_processor: BaseStateEventProcessor,
1785+
emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
1786+
token: str,
1787+
storage: type[rx.LocalStorage] | type[rx.SessionStorage] | type[rx.Cookie],
1788+
cached: bool,
1789+
asynchronous: bool,
1790+
with_load: bool,
1791+
with_hashes: bool,
1792+
):
1793+
"""Snapshot evaluation preserves storage and plain-var writes for delivery.
1794+
1795+
Args:
1796+
wired_app: The app wired to the state manager.
1797+
real_base_state_processor: The event processor.
1798+
emitted_deltas: Captured deltas.
1799+
token: The client token.
1800+
storage: The client-storage type.
1801+
cached: Whether to cache the computed var.
1802+
asynchronous: Whether its evaluation is asynchronous.
1803+
with_load: Whether the page has an on-load handler.
1804+
with_hashes: Whether this is the first boot or a reconnect.
1805+
"""
1806+
1807+
class StorageState(State):
1808+
value: str = storage("")
1809+
status: str = "initial"
1810+
loaded_value: str = "initial"
1811+
1812+
@rx.var
1813+
def before_validation(self) -> str:
1814+
"""Expose values captured before the validating computed var runs.
1815+
1816+
Returns:
1817+
The status and storage value.
1818+
"""
1819+
return f"{self.status}:{self.value}"
1820+
1821+
def _sanitize(self) -> str:
1822+
"""Clear rejected storage and update a plain var.
1823+
1824+
Returns:
1825+
The current storage value.
1826+
"""
1827+
if self.value == "bad":
1828+
self.value = ""
1829+
self.status = "cleared"
1830+
return self.value
1831+
1832+
if asynchronous:
1833+
1834+
@rx.var(cache=cached)
1835+
async def checked(self) -> str: # pyright: ignore[reportRedeclaration]
1836+
"""Validate storage asynchronously.
1837+
1838+
Returns:
1839+
The current storage value.
1840+
"""
1841+
await asyncio.sleep(0)
1842+
return self._sanitize()
1843+
1844+
else:
1845+
1846+
@rx.var(cache=cached)
1847+
def checked(self) -> str:
1848+
"""Validate storage.
1849+
1850+
Returns:
1851+
The current storage value.
1852+
"""
1853+
return self._sanitize()
1854+
1855+
@event
1856+
def load(self):
1857+
"""Record the normalized value seen by on-load handlers."""
1858+
self.loaded_value = self.value
1859+
1860+
wired_app.add_page(
1861+
lambda: rx.text(StorageState.checked, StorageState.before_validation),
1862+
route="/",
1863+
on_load=StorageState.load if with_load else None,
1864+
)
1865+
wired_app._compile_page("index")
1866+
hashes = state_snapshot_hashes(compile_state(State))
1867+
name = StorageState.get_full_name()
1868+
key = "value" + FIELD_MARKER
1869+
payload: dict[str, Any] = {"vars": {f"{name}.{key}": "bad"}}
1870+
if with_hashes:
1871+
payload["hashes"] = hashes
1872+
boot_name = Event.from_event_type(State.hydrate_and_load())[0].name # pyright: ignore[reportCallIssue]
1873+
1874+
async with real_base_state_processor as processor:
1875+
await (
1876+
await processor.enqueue(token, _boot_event(boot_name, payload))
1877+
).wait_all()
1878+
1879+
snapshot = emitted_deltas[0][1]
1880+
assert snapshot.get(name, {}).get(key, "") == ""
1881+
assert snapshot[name]["status" + FIELD_MARKER] == "cleared"
1882+
assert snapshot[name]["before_validation" + FIELD_MARKER] == "cleared:"
1883+
assert any(
1884+
delta.get(name, {}).get(key) == ""
1885+
and delta.get(State.get_full_name(), {}).get(
1886+
CompileVars.IS_HYDRATED + FIELD_MARKER
1887+
)
1888+
is not False
1889+
for _, delta in emitted_deltas[1:]
1890+
)
1891+
if with_load:
1892+
assert any(
1893+
delta.get(name, {}).get("loaded_value" + FIELD_MARKER) == ""
1894+
for _, delta in emitted_deltas[1:]
1895+
)
1896+
async with _read_back(real_base_state_processor, token) as root:
1897+
stored = await root.get_state(StorageState)
1898+
assert stored.value == ""
1899+
assert stored.status == "cleared"
1900+
assert stored.loaded_value == ("" if with_load else "initial")
1901+
1902+
17211903
@pytest.mark.parametrize(
17221904
"processor_state_manager", ["in_process", "redis"], indirect=True
17231905
)

0 commit comments

Comments
 (0)