Skip to content

Commit 7ccaa4e

Browse files
Compute client storage dependents once per hydrate (#7568)
* fix(state): compute client storage dependents once per hydrate hydrate_and_load passes the browser's client storage values through get_delta overrides after the snapshot. It marked them dirty with _mark_dirty, so their computed dependents were invalidated and evaluated a second time, although the snapshot had already sent them computed from the same values. Build that delta with only the dirty fields: build_delta skips invalidating and sending computed vars while _send_fields_only is set. The overrides still see the browser values once, and what they return still reaches browser storage. Fixes #7508 * docs(news): limit hydrate fix note to cached computed vars * fix(state): clean hydrate storage pass when get_delta raises * perf(delta): build delta_vars once on the normal path
1 parent 8b48dc0 commit 7ccaa4e

4 files changed

Lines changed: 179 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cached computed vars that depend on client storage (`rx.LocalStorage`, `rx.SessionStorage`, `rx.Cookie`) are now evaluated once per page load instead of twice.

‎reflex/istate/delta.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
)
4343

4444

45+
# True while a delta is built for dirty fields whose computed dependents the
46+
# client already holds current, so the traversal neither invalidates nor
47+
# evaluates any computed var.
48+
_send_fields_only: ContextVar[bool] = ContextVar("_send_fields_only", default=False)
49+
50+
4551
class _DeltaRecord(NamedTuple):
4652
"""An uncached var value that counts as sent once the delta delivers it."""
4753

@@ -227,8 +233,12 @@ def build_delta(state: BaseState) -> Delta:
227233
"""
228234
delta = {}
229235

230-
state._mark_dirty_computed_vars()
231-
delta_vars = state.dirty_vars & state._frontend_var_names
236+
if _send_fields_only.get():
237+
delta_vars = state.dirty_vars & state._frontend_var_names
238+
delta_vars.difference_update(state.computed_vars)
239+
else:
240+
state._mark_dirty_computed_vars()
241+
delta_vars = state.dirty_vars & state._frontend_var_names
232242

233243
always_dirty_computed_vars = state._always_dirty_computed_vars
234244
# Where to leave the values this traversal sends, for whoever delivers

‎reflex/state.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
Delta,
9191
DeltaMapping,
9292
_resolve_delta,
93+
_send_fields_only,
9394
build_delta,
9495
clean_state,
9596
resolve_delta,
@@ -2439,10 +2440,12 @@ async def hydrate_and_load(
24392440
storage reset, the browser-provided client storage values and the
24402441
state snapshot under one state lock avoids separate load/persist
24412442
cycles of the state tree. The snapshot does not pass through
2442-
``get_delta`` and may not write browser storage, so this event's own
2443-
delta carries the browser-provided values again, as
2443+
``get_delta`` and may not write browser storage, so a second delta
2444+
carries the browser-provided values again, as
24442445
``update_vars_internal``'s does: ``get_delta`` overrides see them
2445-
there, and the browser stores what they return. A page with on_load
2446+
there, and the browser stores what they return. That delta holds only
2447+
those values, because the snapshot already sent the computed vars
2448+
derived from them. A page with on_load
24462449
handlers then gets ``on_load_internal``, which only locks its leaf
24472450
substate.
24482451
@@ -2478,11 +2481,19 @@ async def hydrate_and_load(
24782481
await ctx.emit_delta(delta=delta)
24792482
# Follow-up corrections must be allowed to write browser storage.
24802483
self.dirty_vars.discard(constants.CompileVars.IS_HYDRATED)
2481-
# The browser's values only: the reset defaults stay clean, so they are
2482-
# not written back to the browser.
2483-
for var_state, var_name in applied:
2484-
var_state.dirty_vars.add(var_name)
2485-
var_state._mark_dirty((var_name,))
2484+
if applied:
2485+
# Pass the browser's values through get_delta overrides. The
2486+
# snapshot already sent their dependents, computed from them.
2487+
for var_state, var_name in applied:
2488+
var_state.dirty_vars.add(var_name)
2489+
var_state._mark_ancestors_dirty()
2490+
token = _send_fields_only.set(True)
2491+
try:
2492+
delta = await self._get_resolved_delta()
2493+
finally:
2494+
_send_fields_only.reset(token)
2495+
self._clean()
2496+
await ctx.emit_delta(delta=delta)
24862497
if not RegistrationContext.get().app.get_load_events(self.rx_router_url.path):
24872498
self.is_hydrated = True
24882499
return None

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

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for BaseStateEventProcessor, specifically the _rehydrate path."""
22

33
import asyncio
4+
import collections
45
import contextlib
56
import dataclasses
67
import gc
@@ -2247,6 +2248,152 @@ def load(self):
22472248
assert not any("unset" + FIELD_MARKER in subdelta for subdelta in persisted)
22482249

22492250

2251+
@pytest.mark.parametrize(
2252+
"processor_state_manager", ["in_process", "redis"], indirect=True
2253+
)
2254+
async def test_hydrate_computes_client_storage_dependents_once(
2255+
wired_app: App,
2256+
real_base_state_processor: BaseStateEventProcessor,
2257+
emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
2258+
token: str,
2259+
):
2260+
"""Computed vars depending on browser storage are evaluated once per hydrate.
2261+
2262+
The snapshot already computes them from the browser's values, so passing
2263+
those values through get_delta again must not recompute them.
2264+
2265+
Args:
2266+
wired_app: The app wired to the state manager.
2267+
real_base_state_processor: The event processor.
2268+
emitted_deltas: Captured deltas.
2269+
token: The client token.
2270+
"""
2271+
calls: collections.Counter[str] = collections.Counter()
2272+
2273+
class StorageState(State):
2274+
value: str = rx.LocalStorage("")
2275+
2276+
@rx.var(cache=True)
2277+
def upper(self) -> str:
2278+
calls["upper"] += 1
2279+
return self.value.upper()
2280+
2281+
@rx.var(cache=False)
2282+
def length(self) -> int:
2283+
calls["length"] += 1
2284+
return len(self.value)
2285+
2286+
class DependentState(State):
2287+
@rx.var(cache=True)
2288+
async def copy(self) -> str:
2289+
calls["copy"] += 1
2290+
return (await self.get_state(StorageState)).value
2291+
2292+
wired_app.add_page(
2293+
lambda: rx.text(StorageState.upper, StorageState.length, DependentState.copy),
2294+
route="/",
2295+
)
2296+
wired_app._compile_page("index")
2297+
boot_name = Event.from_event_type(State.hydrate_and_load())[0].name # pyright: ignore[reportCallIssue]
2298+
payload = {"vars": {f"{StorageState.get_full_name()}.value{FIELD_MARKER}": "abc"}}
2299+
2300+
async def hydrate(payload: dict[str, Any]) -> collections.Counter[str]:
2301+
"""Hydrate and count the computed var evaluations.
2302+
2303+
Args:
2304+
payload: The boot event payload.
2305+
2306+
Returns:
2307+
The evaluations per computed var.
2308+
"""
2309+
calls.clear()
2310+
async with real_base_state_processor as processor:
2311+
await (
2312+
await processor.enqueue(token, _boot_event(boot_name, payload))
2313+
).wait_all()
2314+
return calls.copy()
2315+
2316+
without_storage = await hydrate({})
2317+
with_storage = await hydrate(payload)
2318+
2319+
assert with_storage["upper"] == 1
2320+
assert with_storage["copy"] == 1
2321+
assert with_storage["length"] == without_storage["length"]
2322+
sent = {
2323+
key: value
2324+
for _, delta in emitted_deltas
2325+
for key, value in {
2326+
**delta.get(StorageState.get_full_name(), {}),
2327+
**delta.get(DependentState.get_full_name(), {}),
2328+
}.items()
2329+
}
2330+
assert sent["upper" + FIELD_MARKER] == "ABC"
2331+
assert sent["copy" + FIELD_MARKER] == "abc"
2332+
2333+
2334+
async def test_hydrate_cleans_client_storage_when_get_delta_raises(
2335+
wired_app: App,
2336+
real_base_state_processor: BaseStateEventProcessor,
2337+
emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
2338+
token: str,
2339+
):
2340+
"""A get_delta override raising on browser storage leaves no dirty fields behind.
2341+
2342+
Args:
2343+
wired_app: The app wired to the state manager.
2344+
real_base_state_processor: The event processor.
2345+
emitted_deltas: Captured deltas.
2346+
token: The client token.
2347+
"""
2348+
key = "value" + FIELD_MARKER
2349+
seen: list[str] = []
2350+
handled: list[Exception] = []
2351+
2352+
class StorageState(State):
2353+
value: str = rx.LocalStorage("")
2354+
2355+
@_override_base_method
2356+
def get_delta(self) -> Delta:
2357+
"""Raise the first time the browser's value passes through.
2358+
2359+
Returns:
2360+
The delta.
2361+
2362+
Raises:
2363+
RuntimeError: On the first browser value.
2364+
"""
2365+
delta = super().get_delta()
2366+
if (value := delta.get(self.get_full_name(), {}).get(key)) is not None:
2367+
seen.append(value)
2368+
if len(seen) == 1:
2369+
msg = "boom"
2370+
raise RuntimeError(msg)
2371+
return delta
2372+
2373+
@event
2374+
def noop(self):
2375+
"""Change nothing."""
2376+
2377+
real_base_state_processor.backend_exception_handler = handled.append
2378+
wired_app.add_page(lambda: rx.text(StorageState.value), route="/")
2379+
wired_app._compile_page("index")
2380+
boot_name = Event.from_event_type(State.hydrate_and_load())[0].name # pyright: ignore[reportCallIssue]
2381+
payload = {"vars": {f"{StorageState.get_full_name()}.{key}": "abc"}}
2382+
2383+
async with real_base_state_processor as processor:
2384+
await processor.enqueue(token, _boot_event(boot_name, payload))
2385+
await processor.join(5)
2386+
emitted_deltas.clear()
2387+
await _send(processor, token, Event.from_event_type(StorageState.noop())[0])
2388+
2389+
assert [type(ex) for ex in handled] == [RuntimeError]
2390+
assert seen == ["abc"]
2391+
assert not any(
2392+
key in delta.get(StorageState.get_full_name(), {})
2393+
for _, delta in emitted_deltas
2394+
)
2395+
2396+
22502397
@pytest.mark.parametrize(
22512398
"processor_state_manager", ["in_process", "redis"], indirect=True
22522399
)

0 commit comments

Comments
 (0)