|
23 | 23 | import reflex as rx |
24 | 24 | from reflex import event |
25 | 25 | from reflex.app import App |
| 26 | +from reflex.compiler.utils import compile_state |
26 | 27 | from reflex.event import Event, EventSpec |
27 | 28 | from reflex.istate.manager import StateManager |
28 | 29 | from reflex.istate.manager.disk import StateManagerDisk |
29 | 30 | from reflex.istate.manager.memory import StateManagerMemory |
30 | 31 | from reflex.istate.manager.redis import StateManagerRedis |
31 | 32 | from reflex.istate.manager.token import BaseStateToken |
32 | 33 | 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 | +) |
34 | 41 | from reflex.utils import types as reflex_types |
35 | 42 | from tests.units.conftest import metric_points |
36 | 43 | from tests.units.mock_redis import mock_redis |
@@ -1630,8 +1637,6 @@ async def test_hydrate_and_load_diffs_against_compiled_defaults( |
1630 | 1637 | emitted_deltas: List to capture emitted deltas. |
1631 | 1638 | token: The client token. |
1632 | 1639 | """ |
1633 | | - from reflex.compiler.utils import compile_state |
1634 | | - from reflex.state import state_snapshot_hashes |
1635 | 1640 |
|
1636 | 1641 | class CookieState(State): |
1637 | 1642 | flavor: str = rx.Cookie("plain") |
@@ -1669,7 +1674,12 @@ def set_python_equal_values(self): |
1669 | 1674 | assert set(snapshot) == {state_name, CookieState.get_full_name()} |
1670 | 1675 | assert set(snapshot[state_name]) == { |
1671 | 1676 | 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 | + ) |
1673 | 1683 | } |
1674 | 1684 | assert snapshot[CookieState.get_full_name()] == { |
1675 | 1685 | "flavor" + FIELD_MARKER: "chocolate" |
@@ -1718,6 +1728,178 @@ def set_python_equal_values(self): |
1718 | 1728 | assert "loads" + FIELD_MARKER in snapshot[CookieState.get_full_name()] |
1719 | 1729 |
|
1720 | 1730 |
|
| 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 | + |
1721 | 1903 | @pytest.mark.parametrize( |
1722 | 1904 | "processor_state_manager", ["in_process", "redis"], indirect=True |
1723 | 1905 | ) |
|
0 commit comments