fix: rehydrate state trees that expire during a background task

FA
2d ago
+137-1

Summary

A background task releases the state lock while it runs. If the session expires in that time, async with self gets a recreated tree. The delta then carries only the handler's changes, and the browser keeps the values of the expired session (#7478).

A foreground event already recovers from this. When the rx.State root has empty router_data, _execute_event runs _rehydrate. This PR makes StateProxy.__aenter__ use the same signal:

  • It restores the router from the event context.
  • It calls BaseStateEventProcessor._rehydrate, now a staticmethod because it did not use self.

The hydrate snapshot carries is_hydrated=false, so the frontend does not write client storage. The router, the dynamic route args and rx.LocalStorage/rx.Cookie values stay correct. The result is the same as a foreground event on main today.

Supersedes #7543. That PR tracks recreated states in every manager and adds a Redis manifest key. This PR needs no new tracking state, and it also covers a Redis tree that expired completely.

Closes #7478

Test plan

  • New unit test test_background_task_rehydrates_state_tree_expired_while_unlocked, for the memory and Redis managers. It fails on main and passes with this change.
  • tests/units/istate, tests/units/reflex_base, test_state.py, test_event.py and test_app.py pass. The istate and event processor tests also pass against a live Redis.
  • A manual run in a small app with a root state, a child state, a cross-state computed var, a ComponentState, an rx.LocalStorage var and a dynamic route. The background task sleeps longer than the token TTL.
    • Memory, full expiry: the expired values reset. pid, the router, is_hydrated and the browser's localStorage are kept.
    • Redis, full expiry and partial expiry: the expired values reset, and pid and localStorage are kept.
    • Control, no expiry: the background update sends no rehydrate snapshot.

Known limit, the same as the foreground path on main: on Redis, hydrate sends only the states loaded in the tree, so a sibling state that the task does not load keeps its old value.