Bläddra i källkod

Client-side storage / State integration (#1629)

Masen Furer 1 år sedan
förälder
incheckning
9fbc75d84a

+ 1 - 0
integration/__init__.py

@@ -0,0 +1 @@
+"""Package for integration tests."""

+ 515 - 0
integration/test_client_storage.py

@@ -0,0 +1,515 @@
+"""Integration tests for client side storage."""
+from __future__ import annotations
+
+import time
+from typing import Generator
+
+import pytest
+from selenium.webdriver.common.by import By
+from selenium.webdriver.remote.webdriver import WebDriver
+
+from reflex.testing import AppHarness
+
+from . import utils
+
+
+def ClientSide():
+    """App for testing client-side state."""
+    import reflex as rx
+
+    class ClientSideState(rx.State):
+        state_var: str = ""
+        input_value: str = ""
+
+        @rx.var
+        def token(self) -> str:
+            return self.get_token()
+
+    class ClientSideSubState(ClientSideState):
+        # cookies with default settings
+        c1: str = rx.Cookie()
+        c2: rx.Cookie = "c2 default"  # type: ignore
+
+        # cookies with custom settings
+        c3: str = rx.Cookie(max_age=2)  # expires after 2 second
+        c4: rx.Cookie = rx.Cookie(same_site="strict")
+        c5: str = rx.Cookie(path="/foo/")  # only accessible on `/foo/`
+        c6: str = rx.Cookie(name="c6")
+        c7: str = rx.Cookie("c7 default")
+
+        # local storage with default settings
+        l1: str = rx.LocalStorage()
+        l2: rx.LocalStorage = "l2 default"  # type: ignore
+
+        # local storage with custom settings
+        l3: str = rx.LocalStorage(name="l3")
+        l4: str = rx.LocalStorage("l4 default")
+
+        def set_var(self):
+            setattr(self, self.state_var, self.input_value)
+            self.state_var = self.input_value = ""
+
+    class ClientSideSubSubState(ClientSideSubState):
+        c1s: str = rx.Cookie()
+        l1s: str = rx.LocalStorage()
+
+        def set_var(self):
+            setattr(self, self.state_var, self.input_value)
+            self.state_var = self.input_value = ""
+
+    def index():
+        return rx.fragment(
+            rx.input(value=ClientSideState.token, is_read_only=True, id="token"),
+            rx.input(
+                placeholder="state var",
+                value=ClientSideState.state_var,
+                on_change=ClientSideState.set_state_var,  # type: ignore
+                id="state_var",
+            ),
+            rx.input(
+                placeholder="input value",
+                value=ClientSideState.input_value,
+                on_change=ClientSideState.set_input_value,  # type: ignore
+                id="input_value",
+            ),
+            rx.button(
+                "Set ClientSideSubState",
+                on_click=ClientSideSubState.set_var,
+                id="set_sub_state",
+            ),
+            rx.button(
+                "Set ClientSideSubSubState",
+                on_click=ClientSideSubSubState.set_var,
+                id="set_sub_sub_state",
+            ),
+            rx.box(ClientSideSubState.c1, id="c1"),
+            rx.box(ClientSideSubState.c2, id="c2"),
+            rx.box(ClientSideSubState.c3, id="c3"),
+            rx.box(ClientSideSubState.c4, id="c4"),
+            rx.box(ClientSideSubState.c5, id="c5"),
+            rx.box(ClientSideSubState.c6, id="c6"),
+            rx.box(ClientSideSubState.c7, id="c7"),
+            rx.box(ClientSideSubState.l1, id="l1"),
+            rx.box(ClientSideSubState.l2, id="l2"),
+            rx.box(ClientSideSubState.l3, id="l3"),
+            rx.box(ClientSideSubState.l4, id="l4"),
+            rx.box(ClientSideSubSubState.c1s, id="c1s"),
+            rx.box(ClientSideSubSubState.l1s, id="l1s"),
+        )
+
+    app = rx.App(state=ClientSideState)
+    app.add_page(index)
+    app.add_page(index, route="/foo")
+    app.compile()
+
+
+@pytest.fixture(scope="session")
+def client_side(tmp_path_factory) -> Generator[AppHarness, None, None]:
+    """Start ClientSide app at tmp_path via AppHarness.
+
+    Args:
+        tmp_path_factory: pytest tmp_path_factory fixture
+
+    Yields:
+        running AppHarness instance
+    """
+    with AppHarness.create(
+        root=tmp_path_factory.mktemp("client_side"),
+        app_source=ClientSide,  # type: ignore
+    ) as harness:
+        yield harness
+
+
+@pytest.fixture
+def driver(client_side: AppHarness) -> Generator[WebDriver, None, None]:
+    """Get an instance of the browser open to the client_side app.
+
+    Args:
+        client_side: harness for ClientSide app
+
+    Yields:
+        WebDriver instance.
+    """
+    assert client_side.app_instance is not None, "app is not running"
+    driver = client_side.frontend()
+    try:
+        assert client_side.poll_for_clients()
+        yield driver
+    finally:
+        driver.quit()
+
+
+@pytest.fixture()
+def local_storage(driver: WebDriver) -> Generator[utils.LocalStorage, None, None]:
+    """Get an instance of the local storage helper.
+
+    Args:
+        driver: WebDriver instance.
+
+    Yields:
+        Local storage helper.
+    """
+    ls = utils.LocalStorage(driver)
+    yield ls
+    ls.clear()
+
+
+@pytest.fixture(autouse=True)
+def delete_all_cookies(driver: WebDriver) -> Generator[None, None, None]:
+    """Delete all cookies after each test.
+
+    Args:
+        driver: WebDriver instance.
+
+    Yields:
+        None
+    """
+    yield
+    driver.delete_all_cookies()
+
+
+def test_client_side_state(
+    client_side: AppHarness, driver: WebDriver, local_storage: utils.LocalStorage
+):
+    """Test client side state.
+
+    Args:
+        client_side: harness for ClientSide app.
+        driver: WebDriver instance.
+        local_storage: Local storage helper.
+    """
+    assert client_side.app_instance is not None
+    assert client_side.frontend_url is not None
+    token_input = driver.find_element(By.ID, "token")
+    assert token_input
+
+    # wait for the backend connection to send the token
+    token = client_side.poll_for_value(token_input)
+    assert token is not None
+
+    backend_state = client_side.app_instance.state_manager.states[token]
+
+    # get a reference to the cookie manipulation form
+    state_var_input = driver.find_element(By.ID, "state_var")
+    input_value_input = driver.find_element(By.ID, "input_value")
+    set_sub_state_button = driver.find_element(By.ID, "set_sub_state")
+    set_sub_sub_state_button = driver.find_element(By.ID, "set_sub_sub_state")
+
+    # get a reference to all cookie and local storage elements
+    c1 = driver.find_element(By.ID, "c1")
+    c2 = driver.find_element(By.ID, "c2")
+    c3 = driver.find_element(By.ID, "c3")
+    c4 = driver.find_element(By.ID, "c4")
+    c5 = driver.find_element(By.ID, "c5")
+    c6 = driver.find_element(By.ID, "c6")
+    c7 = driver.find_element(By.ID, "c7")
+    l1 = driver.find_element(By.ID, "l1")
+    l2 = driver.find_element(By.ID, "l2")
+    l3 = driver.find_element(By.ID, "l3")
+    l4 = driver.find_element(By.ID, "l4")
+    c1s = driver.find_element(By.ID, "c1s")
+    l1s = driver.find_element(By.ID, "l1s")
+
+    # assert on defaults where present
+    assert c1.text == ""
+    assert c2.text == "c2 default"
+    assert c3.text == ""
+    assert c4.text == ""
+    assert c5.text == ""
+    assert c6.text == ""
+    assert c7.text == "c7 default"
+    assert l1.text == ""
+    assert l2.text == "l2 default"
+    assert l3.text == ""
+    assert l4.text == "l4 default"
+    assert c1s.text == ""
+    assert l1s.text == ""
+
+    # no cookies should be set yet!
+    assert not driver.get_cookies()
+    local_storage_items = local_storage.items()
+    local_storage_items.pop("chakra-ui-color-mode", None)
+    assert not local_storage_items
+
+    # set some cookies and local storage values
+    state_var_input.send_keys("c1")
+    input_value_input.send_keys("c1 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c2")
+    input_value_input.send_keys("c2 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c3")
+    input_value_input.send_keys("c3 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c4")
+    input_value_input.send_keys("c4 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c5")
+    input_value_input.send_keys("c5 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c6")
+    input_value_input.send_keys("c6 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("c7")
+    input_value_input.send_keys("c7 value")
+    set_sub_state_button.click()
+
+    state_var_input.send_keys("l1")
+    input_value_input.send_keys("l1 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("l2")
+    input_value_input.send_keys("l2 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("l3")
+    input_value_input.send_keys("l3 value")
+    set_sub_state_button.click()
+    state_var_input.send_keys("l4")
+    input_value_input.send_keys("l4 value")
+    set_sub_state_button.click()
+
+    state_var_input.send_keys("c1s")
+    input_value_input.send_keys("c1s value")
+    set_sub_sub_state_button.click()
+    state_var_input.send_keys("l1s")
+    input_value_input.send_keys("l1s value")
+    set_sub_sub_state_button.click()
+
+    cookies = {cookie_info["name"]: cookie_info for cookie_info in driver.get_cookies()}
+    assert cookies.pop("client_side_state.client_side_sub_state.c1") == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c1",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c1%20value",
+    }
+    assert cookies.pop("client_side_state.client_side_sub_state.c2") == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c2",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c2%20value",
+    }
+    c3_cookie = cookies.pop("client_side_state.client_side_sub_state.c3")
+    assert c3_cookie.pop("expiry") is not None
+    assert c3_cookie == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c3",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c3%20value",
+    }
+    assert cookies.pop("client_side_state.client_side_sub_state.c4") == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c4",
+        "path": "/",
+        "sameSite": "Strict",
+        "secure": False,
+        "value": "c4%20value",
+    }
+    assert cookies.pop("c6") == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "c6",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c6%20value",
+    }
+    assert cookies.pop("client_side_state.client_side_sub_state.c7") == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c7",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c7%20value",
+    }
+    assert cookies.pop(
+        "client_side_state.client_side_sub_state.client_side_sub_sub_state.c1s"
+    ) == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.client_side_sub_sub_state.c1s",
+        "path": "/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c1s%20value",
+    }
+    # assert all cookies have been popped for this page
+    assert not cookies
+    time.sleep(2)  # wait for c3 to expire
+    assert "client_side_state.client_side_sub_state.c3" not in {
+        cookie_info["name"] for cookie_info in driver.get_cookies()
+    }
+
+    local_storage_items = local_storage.items()
+    local_storage_items.pop("chakra-ui-color-mode", None)
+    assert (
+        local_storage_items.pop("client_side_state.client_side_sub_state.l1")
+        == "l1 value"
+    )
+    assert (
+        local_storage_items.pop("client_side_state.client_side_sub_state.l2")
+        == "l2 value"
+    )
+    assert local_storage_items.pop("l3") == "l3 value"
+    assert (
+        local_storage_items.pop("client_side_state.client_side_sub_state.l4")
+        == "l4 value"
+    )
+    assert (
+        local_storage_items.pop(
+            "client_side_state.client_side_sub_state.client_side_sub_sub_state.l1s"
+        )
+        == "l1s value"
+    )
+    assert not local_storage_items
+
+    assert c1.text == "c1 value"
+    assert c2.text == "c2 value"
+    assert c3.text == "c3 value"
+    assert c4.text == "c4 value"
+    assert c5.text == "c5 value"
+    assert c6.text == "c6 value"
+    assert c7.text == "c7 value"
+    assert l1.text == "l1 value"
+    assert l2.text == "l2 value"
+    assert l3.text == "l3 value"
+    assert l4.text == "l4 value"
+    assert c1s.text == "c1s value"
+    assert l1s.text == "l1s value"
+
+    # navigate to the /foo route
+    with utils.poll_for_navigation(driver):
+        driver.get(client_side.frontend_url + "/foo")
+
+    # get new references to all cookie and local storage elements
+    c1 = driver.find_element(By.ID, "c1")
+    c2 = driver.find_element(By.ID, "c2")
+    c3 = driver.find_element(By.ID, "c3")
+    c4 = driver.find_element(By.ID, "c4")
+    c5 = driver.find_element(By.ID, "c5")
+    c6 = driver.find_element(By.ID, "c6")
+    c7 = driver.find_element(By.ID, "c7")
+    l1 = driver.find_element(By.ID, "l1")
+    l2 = driver.find_element(By.ID, "l2")
+    l3 = driver.find_element(By.ID, "l3")
+    l4 = driver.find_element(By.ID, "l4")
+    c1s = driver.find_element(By.ID, "c1s")
+    l1s = driver.find_element(By.ID, "l1s")
+
+    assert c1.text == "c1 value"
+    assert c2.text == "c2 value"
+    assert c3.text == ""  # cookie expired so value removed from state
+    assert c4.text == "c4 value"
+    assert c5.text == "c5 value"
+    assert c6.text == "c6 value"
+    assert c7.text == "c7 value"
+    assert l1.text == "l1 value"
+    assert l2.text == "l2 value"
+    assert l3.text == "l3 value"
+    assert l4.text == "l4 value"
+    assert c1s.text == "c1s value"
+    assert l1s.text == "l1s value"
+
+    # reset the backend state to force refresh from client storage
+    backend_state.reset()
+    driver.refresh()
+
+    # wait for the backend connection to send the token (again)
+    token_input = driver.find_element(By.ID, "token")
+    assert token_input
+    token = client_side.poll_for_value(token_input)
+    assert token is not None
+
+    # get new references to all cookie and local storage elements (again)
+    c1 = driver.find_element(By.ID, "c1")
+    c2 = driver.find_element(By.ID, "c2")
+    c3 = driver.find_element(By.ID, "c3")
+    c4 = driver.find_element(By.ID, "c4")
+    c5 = driver.find_element(By.ID, "c5")
+    c6 = driver.find_element(By.ID, "c6")
+    c7 = driver.find_element(By.ID, "c7")
+    l1 = driver.find_element(By.ID, "l1")
+    l2 = driver.find_element(By.ID, "l2")
+    l3 = driver.find_element(By.ID, "l3")
+    l4 = driver.find_element(By.ID, "l4")
+    c1s = driver.find_element(By.ID, "c1s")
+    l1s = driver.find_element(By.ID, "l1s")
+
+    assert c1.text == "c1 value"
+    assert c2.text == "c2 value"
+    assert c3.text == ""  # temporary cookie expired after reset state!
+    assert c4.text == "c4 value"
+    assert c5.text == "c5 value"
+    assert c6.text == "c6 value"
+    assert c7.text == "c7 value"
+    assert l1.text == "l1 value"
+    assert l2.text == "l2 value"
+    assert l3.text == "l3 value"
+    assert l4.text == "l4 value"
+    assert c1s.text == "c1s value"
+    assert l1s.text == "l1s value"
+
+    # make sure c5 cookie shows up on the `/foo` route
+    cookies = {cookie_info["name"]: cookie_info for cookie_info in driver.get_cookies()}
+
+    assert cookies["client_side_state.client_side_sub_state.c5"] == {
+        "domain": "localhost",
+        "httpOnly": False,
+        "name": "client_side_state.client_side_sub_state.c5",
+        "path": "/foo/",
+        "sameSite": "Lax",
+        "secure": False,
+        "value": "c5%20value",
+    }
+
+    # clear the cookie jar and local storage, ensure state reset to default
+    driver.delete_all_cookies()
+    local_storage.clear()
+
+    # refresh the page to trigger re-hydrate
+    driver.refresh()
+
+    # wait for the backend connection to send the token (again)
+    token_input = driver.find_element(By.ID, "token")
+    assert token_input
+    token = client_side.poll_for_value(token_input)
+    assert token is not None
+
+    # all values should be back to their defaults
+    c1 = driver.find_element(By.ID, "c1")
+    c2 = driver.find_element(By.ID, "c2")
+    c3 = driver.find_element(By.ID, "c3")
+    c4 = driver.find_element(By.ID, "c4")
+    c5 = driver.find_element(By.ID, "c5")
+    c6 = driver.find_element(By.ID, "c6")
+    c7 = driver.find_element(By.ID, "c7")
+    l1 = driver.find_element(By.ID, "l1")
+    l2 = driver.find_element(By.ID, "l2")
+    l3 = driver.find_element(By.ID, "l3")
+    l4 = driver.find_element(By.ID, "l4")
+    c1s = driver.find_element(By.ID, "c1s")
+    l1s = driver.find_element(By.ID, "l1s")
+
+    # assert on defaults where present
+    assert c1.text == ""
+    assert c2.text == "c2 default"
+    assert c3.text == ""
+    assert c4.text == ""
+    assert c5.text == ""
+    assert c6.text == ""
+    assert c7.text == "c7 default"
+    assert l1.text == ""
+    assert l2.text == "l2 default"
+    assert l3.text == ""
+    assert l4.text == "l4 default"
+    assert c1s.text == ""
+    assert l1s.text == ""

+ 2 - 22
integration/test_dynamic_routes.py

@@ -1,6 +1,5 @@
 """Integration tests for dynamic route page behavior."""
 """Integration tests for dynamic route page behavior."""
 import time
 import time
-from contextlib import contextmanager
 from typing import Generator
 from typing import Generator
 from urllib.parse import urlsplit
 from urllib.parse import urlsplit
 
 
@@ -9,6 +8,8 @@ from selenium.webdriver.common.by import By
 
 
 from reflex.testing import AppHarness
 from reflex.testing import AppHarness
 
 
+from .utils import poll_for_navigation
+
 
 
 def DynamicRoute():
 def DynamicRoute():
     """App for testing dynamic routes."""
     """App for testing dynamic routes."""
@@ -89,27 +90,6 @@ def driver(dynamic_route: AppHarness):
         driver.quit()
         driver.quit()
 
 
 
 
-@contextmanager
-def poll_for_navigation(driver, timeout: int = 5) -> Generator[None, None, None]:
-    """Wait for driver url to change.
-
-    Use as a contextmanager, and apply the navigation event inside the context
-    block, polling will occur after the context block exits.
-
-    Args:
-        driver: WebDriver instance.
-        timeout: Time to wait for url to change.
-
-    Yields:
-        None
-    """
-    prev_url = driver.current_url
-
-    yield
-
-    AppHarness._poll_for(lambda: prev_url != driver.current_url, timeout=timeout)
-
-
 def test_on_load_navigate(dynamic_route: AppHarness, driver):
 def test_on_load_navigate(dynamic_route: AppHarness, driver):
     """Click links to navigate between dynamic pages with on_load event.
     """Click links to navigate between dynamic pages with on_load event.
 
 

+ 173 - 0
integration/utils.py

@@ -0,0 +1,173 @@
+"""Helper utilities for integration tests."""
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Generator, Iterator
+
+from selenium.webdriver.remote.webdriver import WebDriver
+
+from reflex.testing import AppHarness
+
+
+@contextmanager
+def poll_for_navigation(
+    driver: WebDriver, timeout: int = 5
+) -> Generator[None, None, None]:
+    """Wait for driver url to change.
+
+    Use as a contextmanager, and apply the navigation event inside the context
+    block, polling will occur after the context block exits.
+
+    Args:
+        driver: WebDriver instance.
+        timeout: Time to wait for url to change.
+
+    Yields:
+        None
+    """
+    prev_url = driver.current_url
+
+    yield
+
+    AppHarness._poll_for(lambda: prev_url != driver.current_url, timeout=timeout)
+
+
+class LocalStorage:
+    """Class to access local storage.
+
+    https://stackoverflow.com/a/46361900
+    """
+
+    def __init__(self, driver: WebDriver):
+        """Initialize the class.
+
+        Args:
+            driver: WebDriver instance.
+        """
+        self.driver = driver
+
+    def __len__(self) -> int:
+        """Get the number of items in local storage.
+
+        Returns:
+            The number of items in local storage.
+        """
+        return int(self.driver.execute_script("return window.localStorage.length;"))
+
+    def items(self) -> dict[str, str]:
+        """Get all items in local storage.
+
+        Returns:
+            A dict mapping keys to values.
+        """
+        return self.driver.execute_script(
+            "var ls = window.localStorage, items = {}; "
+            "for (var i = 0, k; i < ls.length; ++i) "
+            "  items[k = ls.key(i)] = ls.getItem(k); "
+            "return items; "
+        )
+
+    def keys(self) -> list[str]:
+        """Get all keys in local storage.
+
+        Returns:
+            A list of keys.
+        """
+        return self.driver.execute_script(
+            "var ls = window.localStorage, keys = []; "
+            "for (var i = 0; i < ls.length; ++i) "
+            "  keys[i] = ls.key(i); "
+            "return keys; "
+        )
+
+    def get(self, key) -> str:
+        """Get a key from local storage.
+
+        Args:
+            key: The key to get.
+
+        Returns:
+            The value of the key.
+        """
+        return self.driver.execute_script(
+            "return window.localStorage.getItem(arguments[0]);", key
+        )
+
+    def set(self, key, value) -> None:
+        """Set a key in local storage.
+
+        Args:
+            key: The key to set.
+            value: The value to set the key to.
+        """
+        self.driver.execute_script(
+            "window.localStorage.setItem(arguments[0], arguments[1]);", key, value
+        )
+
+    def has(self, key) -> bool:
+        """Check if key is in local storage.
+
+        Args:
+            key: The key to check.
+
+        Returns:
+            True if key is in local storage, False otherwise.
+        """
+        return key in self
+
+    def remove(self, key) -> None:
+        """Remove a key from local storage.
+
+        Args:
+            key: The key to remove.
+        """
+        self.driver.execute_script("window.localStorage.removeItem(arguments[0]);", key)
+
+    def clear(self) -> None:
+        """Clear all local storage."""
+        self.driver.execute_script("window.localStorage.clear();")
+
+    def __getitem__(self, key) -> str:
+        """Get a key from local storage.
+
+        Args:
+            key: The key to get.
+
+        Returns:
+            The value of the key.
+
+        Raises:
+            KeyError: If key is not in local storage.
+        """
+        value = self.get(key)
+        if value is None:
+            raise KeyError(key)
+        return value
+
+    def __setitem__(self, key, value) -> None:
+        """Set a key in local storage.
+
+        Args:
+            key: The key to set.
+            value: The value to set the key to.
+        """
+        self.set(key, value)
+
+    def __contains__(self, key) -> bool:
+        """Check if key is in local storage.
+
+        Args:
+            key: The key to check.
+
+        Returns:
+            True if key is in local storage, False otherwise.
+        """
+        return self.has(key)
+
+    def __iter__(self) -> Iterator[str]:
+        """Iterate over the keys in local storage.
+
+        Returns:
+            An iterator over the items in local storage.
+        """
+        return iter(self.keys())

+ 4 - 4
poetry.lock

@@ -1520,13 +1520,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "selenium"
 name = "selenium"
-version = "4.10.0"
+version = "4.11.2"
 description = ""
 description = ""
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "selenium-4.10.0-py3-none-any.whl", hash = "sha256:40241b9d872f58959e9b34e258488bf11844cd86142fd68182bd41db9991fc5c"},
-    {file = "selenium-4.10.0.tar.gz", hash = "sha256:871bf800c4934f745b909c8dfc7d15c65cf45bd2e943abd54451c810ada395e3"},
+    {file = "selenium-4.11.2-py3-none-any.whl", hash = "sha256:98e72117b194b3fa9c69b48998f44bf7dd4152c7bd98544911a1753b9f03cc7d"},
+    {file = "selenium-4.11.2.tar.gz", hash = "sha256:9f9a5ed586280a3594f7461eb1d9dab3eac9d91e28572f365e9b98d9d03e02b5"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -2125,4 +2125,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 python-versions = "^3.7"
-content-hash = "2b00be45f1c3b5118e2d54b315991c37f65e9da3fa081dab6adb4c7bb1205c74"
+content-hash = "44cce3d4423be203bf6b1ddc046cbdd9061924523b86baea8a42cd954dc86b36"

+ 1 - 1
pyproject.toml

@@ -65,7 +65,7 @@ pandas = [
 ]
 ]
 asynctest = "^0.13.0"
 asynctest = "^0.13.0"
 pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"}
 pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"}
-selenium = "^4.10.0"
+selenium = "^4.11.0"
 
 
 [tool.poetry.scripts]
 [tool.poetry.scripts]
 reflex = "reflex.reflex:cli"
 reflex = "reflex.reflex:cli"

+ 6 - 3
reflex/.templates/jinja/web/utils/context.js.jinja2

@@ -1,7 +1,10 @@
 import { createContext } from "react"
 import { createContext } from "react"
-import { E } from "/utils/state.js"
+import { E, hydrateClientStorage } from "/utils/state.js"
 
 
 export const initialState = {{ initial_state|json_dumps }}
 export const initialState = {{ initial_state|json_dumps }}
-export const initialEvents = [E('{{state_name}}.{{const.hydrate}}', {})]
 export const StateContext = createContext(null);
 export const StateContext = createContext(null);
-export const EventLoopContext = createContext(null);
+export const EventLoopContext = createContext(null);
+export const clientStorage = {{ client_storage|json_dumps }}
+export const initialEvents = [
+    E('{{state_name}}.{{const.hydrate}}', hydrateClientStorage(clientStorage)),
+]

+ 2 - 1
reflex/.templates/web/pages/_app.js

@@ -1,7 +1,7 @@
 import { ChakraProvider, extendTheme } from "@chakra-ui/react";
 import { ChakraProvider, extendTheme } from "@chakra-ui/react";
 import { Global, css } from "@emotion/react";
 import { Global, css } from "@emotion/react";
 import theme from "/utils/theme";
 import theme from "/utils/theme";
-import { initialEvents, initialState, StateContext, EventLoopContext } from "/utils/context.js";
+import { clientStorage, initialEvents, initialState, StateContext, EventLoopContext } from "/utils/context.js";
 import { useEventLoop } from "utils/state";
 import { useEventLoop } from "utils/state";
 
 
 import '../styles/tailwind.css'
 import '../styles/tailwind.css'
@@ -18,6 +18,7 @@ function EventLoopProvider({ children }) {
   const [state, Event, connectError] = useEventLoop(
   const [state, Event, connectError] = useEventLoop(
     initialState,
     initialState,
     initialEvents,
     initialEvents,
+    clientStorage,
   )
   )
   return (
   return (
     <EventLoopContext.Provider value={[Event, connectError]}>
     <EventLoopContext.Provider value={[Event, connectError]}>

+ 74 - 3
reflex/.templates/web/utils/state.js

@@ -125,12 +125,12 @@ export const applyEvent = async (event, socket) => {
   }
   }
 
 
   if (event.name == "_set_cookie") {
   if (event.name == "_set_cookie") {
-    cookies.set(event.payload.key, event.payload.value);
+    cookies.set(event.payload.key, event.payload.value, { path: "/" });
     return false;
     return false;
   }
   }
 
 
   if (event.name == "_remove_cookie") {
   if (event.name == "_remove_cookie") {
-    cookies.remove(event.payload.key, event.payload.options)
+    cookies.remove(event.payload.key, { path: "/", ...event.payload.options })
     return false;
     return false;
   }
   }
 
 
@@ -257,6 +257,7 @@ export const processEvent = async (
  * @param transports The transports to use.
  * @param transports The transports to use.
  * @param setConnectError The function to update connection error value.
  * @param setConnectError The function to update connection error value.
  * @param initial_events Array of events to seed the queue after connecting.
  * @param initial_events Array of events to seed the queue after connecting.
+ * @param client_storage The client storage object from context.js
  */
  */
 export const connect = async (
 export const connect = async (
   socket,
   socket,
@@ -264,6 +265,7 @@ export const connect = async (
   transports,
   transports,
   setConnectError,
   setConnectError,
   initial_events = [],
   initial_events = [],
+  client_storage = {},
 ) => {
 ) => {
   // Get backend URL object from the endpoint.
   // Get backend URL object from the endpoint.
   const endpoint = new URL(EVENTURL);
   const endpoint = new URL(EVENTURL);
@@ -288,6 +290,7 @@ export const connect = async (
   socket.current.on("event", message => {
   socket.current.on("event", message => {
     const update = JSON5.parse(message)
     const update = JSON5.parse(message)
     dispatch(update.delta)
     dispatch(update.delta)
+    applyClientStorageDelta(client_storage, update.delta)
     event_processing = !update.final
     event_processing = !update.final
     if (update.events) {
     if (update.events) {
       queueEvents(update.events, socket)
       queueEvents(update.events, socket)
@@ -357,10 +360,77 @@ export const E = (name, payload = {}, handler = null) => {
   return { name, payload, handler };
   return { name, payload, handler };
 };
 };
 
 
+/**
+ * Package client-side storage values as payload to send to the
+ * backend with the hydrate event
+ * @param client_storage The client storage object from context.js
+ * @returns payload dict of client storage values
+ */
+export const hydrateClientStorage = (client_storage) => {
+  const client_storage_values = {
+    "cookies": {},
+    "local_storage": {}
+  }
+  if (client_storage.cookies) {
+    for (const state_key in client_storage.cookies) {
+      const cookie_options = client_storage.cookies[state_key]
+      const cookie_name = cookie_options.name || state_key
+      client_storage_values.cookies[state_key] = cookies.get(cookie_name)
+    }
+  }
+  if (client_storage.local_storage && (typeof window !== 'undefined')) {
+    for (const state_key in client_storage.local_storage) {
+      const options = client_storage.local_storage[state_key]
+      const local_storage_value = localStorage.getItem(options.name || state_key)
+      if (local_storage_value !== null) {
+        client_storage_values.local_storage[state_key] = local_storage_value
+      }
+    }
+  }
+  if (client_storage.cookies || client_storage.local_storage) {
+    return client_storage_values
+  }
+  return {}
+};
+
+/**
+ * Update client storage values based on backend state delta.
+ * @param client_storage The client storage object from context.js
+ * @param delta The state update from the backend
+ */
+const applyClientStorageDelta = (client_storage, delta) => {
+  // find the main state and check for is_hydrated
+  const unqualified_states = Object.keys(delta).filter((key) => key.split(".").length === 1);
+  if (unqualified_states.length === 1) {
+    const main_state = delta[unqualified_states[0]]
+    if (main_state.is_hydrated !== undefined && !main_state.is_hydrated) {
+      // skip if the state is not hydrated yet, since all client storage
+      // values are sent in the hydrate event
+      return;
+    }
+  }
+  // Save known client storage values to cookies and localStorage.
+  for (const substate in delta) {
+    for (const key in delta[substate]) {
+      const state_key = `${substate}.${key}`
+      if (client_storage.cookies && state_key in client_storage.cookies) {
+        const cookie_options = client_storage.cookies[state_key]
+        const cookie_name = cookie_options.name || state_key
+        delete cookie_options.name  // name is not a valid cookie option
+        cookies.set(cookie_name, delta[substate][key], cookie_options);
+      } else if (client_storage.local_storage && state_key in client_storage.local_storage && (typeof window !== 'undefined')) {
+        const options = client_storage.local_storage[state_key]
+        localStorage.setItem(options.name || state_key, delta[substate][key]);
+      }
+    }
+  }
+}
+
 /**
 /**
  * Establish websocket event loop for a NextJS page.
  * Establish websocket event loop for a NextJS page.
  * @param initial_state The initial page state.
  * @param initial_state The initial page state.
  * @param initial_events Array of events to seed the queue after connecting.
  * @param initial_events Array of events to seed the queue after connecting.
+ * @param client_storage The client storage object from context.js
  *
  *
  * @returns [state, Event, connectError] -
  * @returns [state, Event, connectError] -
  *   state is a reactive dict,
  *   state is a reactive dict,
@@ -370,6 +440,7 @@ export const E = (name, payload = {}, handler = null) => {
 export const useEventLoop = (
 export const useEventLoop = (
   initial_state = {},
   initial_state = {},
   initial_events = [],
   initial_events = [],
+  client_storage = {},
 ) => {
 ) => {
   const socket = useRef(null)
   const socket = useRef(null)
   const router = useRouter()
   const router = useRouter()
@@ -391,7 +462,7 @@ export const useEventLoop = (
 
 
     // Initialize the websocket connection.
     // Initialize the websocket connection.
     if (!socket.current) {
     if (!socket.current) {
-      connect(socket, dispatch, ['websocket', 'polling'], setConnectError, initial_events)
+      connect(socket, dispatch, ['websocket', 'polling'], setConnectError, initial_events, client_storage)
     }
     }
     (async () => {
     (async () => {
       // Process all outstanding events.
       // Process all outstanding events.

+ 2 - 0
reflex/__init__.py

@@ -38,6 +38,8 @@ from .model import session as session
 from .page import page as page
 from .page import page as page
 from .route import route as route
 from .route import route as route
 from .state import ComputedVar as var
 from .state import ComputedVar as var
+from .state import Cookie as Cookie
+from .state import LocalStorage as LocalStorage
 from .state import State as State
 from .state import State as State
 from .style import color_mode as color_mode
 from .style import color_mode as color_mode
 from .style import toggle_color_mode as toggle_color_mode
 from .style import toggle_color_mode as toggle_color_mode

+ 1 - 0
reflex/compiler/compiler.py

@@ -83,6 +83,7 @@ def _compile_contexts(state: Type[State]) -> str:
     return templates.CONTEXT.render(
     return templates.CONTEXT.render(
         initial_state=utils.compile_state(state),
         initial_state=utils.compile_state(state),
         state_name=state.get_name(),
         state_name=state.get_name(),
+        client_storage=utils.compile_client_storage(state),
     )
     )
 
 
 
 

+ 82 - 2
reflex/compiler/utils.py

@@ -1,7 +1,10 @@
 """Common utility functions used in the compiler."""
 """Common utility functions used in the compiler."""
+from __future__ import annotations
 
 
 import os
 import os
-from typing import Dict, List, Optional, Set, Tuple, Type
+from typing import Any, Dict, List, Optional, Set, Tuple, Type
+
+from pydantic.fields import ModelField
 
 
 from reflex import constants
 from reflex import constants
 from reflex.components.base import (
 from reflex.components.base import (
@@ -19,7 +22,7 @@ from reflex.components.base import (
     Title,
     Title,
 )
 )
 from reflex.components.component import Component, ComponentStyle, CustomComponent
 from reflex.components.component import Component, ComponentStyle, CustomComponent
-from reflex.state import State
+from reflex.state import Cookie, LocalStorage, State
 from reflex.style import Style
 from reflex.style import Style
 from reflex.utils import format, imports, path_ops
 from reflex.utils import format, imports, path_ops
 from reflex.vars import ImportVar
 from reflex.vars import ImportVar
@@ -129,6 +132,83 @@ def compile_state(state: Type[State]) -> Dict:
     return format.format_state(initial_state)
     return format.format_state(initial_state)
 
 
 
 
+def _compile_client_storage_field(
+    field: ModelField,
+) -> tuple[Type[Cookie] | Type[LocalStorage] | None, dict[str, Any] | None]:
+    """Compile the given cookie or local_storage field.
+
+    Args:
+        field: The possible cookie field to compile.
+
+    Returns:
+        A dictionary of the compiled cookie or None if the field is not cookie-like.
+    """
+    for field_type in (Cookie, LocalStorage):
+        if isinstance(field.default, field_type):
+            cs_obj = field.default
+        elif isinstance(field.type_, type) and issubclass(field.type_, field_type):
+            cs_obj = field.type_()
+        else:
+            continue
+        return field_type, cs_obj.options()
+    return None, None
+
+
+def _compile_client_storage_recursive(
+    state: Type[State],
+) -> tuple[dict[str, dict], dict[str, dict[str, str]]]:
+    """Compile the client-side storage for the given state recursively.
+
+    Args:
+        state: The app state object.
+
+    Returns:
+        A tuple of the compiled client-side storage info:
+            (
+                cookies: dict[str, dict],
+                local_storage: dict[str, dict[str, str]]
+            )
+    """
+    cookies = {}
+    local_storage = {}
+    state_name = state.get_full_name()
+    for name, field in state.__fields__.items():
+        if name in state.inherited_vars:
+            # only include vars defined in this state
+            continue
+        state_key = f"{state_name}.{name}"
+        field_type, options = _compile_client_storage_field(field)
+        if field_type is Cookie:
+            cookies[state_key] = options
+        elif field_type is LocalStorage:
+            local_storage[state_key] = options
+        else:
+            continue
+    for substate in state.get_substates():
+        substate_cookies, substate_local_storage = _compile_client_storage_recursive(
+            substate
+        )
+        cookies.update(substate_cookies)
+        local_storage.update(substate_local_storage)
+    return cookies, local_storage
+
+
+def compile_client_storage(state: Type[State]) -> dict[str, dict]:
+    """Compile the client-side storage for the given state.
+
+    Args:
+        state: The app state object.
+
+    Returns:
+        A dictionary of the compiled client-side storage info.
+    """
+    cookies, local_storage = _compile_client_storage_recursive(state)
+    return {
+        constants.COOKIES: cookies,
+        constants.LOCAL_STORAGE: local_storage,
+    }
+
+
 def compile_custom_component(
 def compile_custom_component(
     component: CustomComponent,
     component: CustomComponent,
 ) -> Tuple[dict, imports.ImportDict]:
 ) -> Tuple[dict, imports.ImportDict]:

+ 4 - 0
reflex/constants.py

@@ -359,6 +359,10 @@ PING_TIMEOUT = 120
 # Alembic migrations
 # Alembic migrations
 ALEMBIC_CONFIG = os.environ.get("ALEMBIC_CONFIG", "alembic.ini")
 ALEMBIC_CONFIG = os.environ.get("ALEMBIC_CONFIG", "alembic.ini")
 
 
+# Keys in the client_side_storage dict
+COOKIES = "cookies"
+LOCAL_STORAGE = "local_storage"
+
 # Names of event handlers on all components mapped to useEffect
 # Names of event handlers on all components mapped to useEffect
 ON_MOUNT = "on_mount"
 ON_MOUNT = "on_mount"
 ON_UNMOUNT = "on_unmount"
 ON_UNMOUNT = "on_unmount"

+ 14 - 1
reflex/middleware/hydrate_middleware.py

@@ -36,8 +36,21 @@ class HydrateMiddleware(Middleware):
         if event.name != get_hydrate_event(state):
         if event.name != get_hydrate_event(state):
             return None
             return None
 
 
-        # Get the initial state.
+        # Clear client storage, to respect clearing cookies
+        state._reset_client_storage()
+
+        # Mark state as not hydrated (until on_loads are complete)
         setattr(state, constants.IS_HYDRATED, False)
         setattr(state, constants.IS_HYDRATED, False)
+
+        # Apply client side storage values to state
+        for storage_type in (constants.COOKIES, constants.LOCAL_STORAGE):
+            if storage_type in event.payload:
+                for key, value in event.payload[storage_type].items():
+                    state_name, _, var_name = key.rpartition(".")
+                    var_state = state.get_substate(state_name.split("."))
+                    setattr(var_state, var_name, value)
+
+        # Get the initial state.
         delta = format.format_state({state.get_name(): state.dict()})
         delta = format.format_state({state.get_name(): state.dict()})
         # since a full dict was captured, clean any dirtiness
         # since a full dict was captured, clean any dirtiness
         state._clean()
         state._clean()

+ 118 - 0
reflex/state.py

@@ -688,6 +688,23 @@ class State(Base, ABC, extra=pydantic.Extra.allow):
         for substate in self.substates.values():
         for substate in self.substates.values():
             substate.reset()
             substate.reset()
 
 
+    def _reset_client_storage(self):
+        """Reset client storage base vars to their default values."""
+        # Client-side storage is reset during hydrate so that clearing cookies
+        # on the browser also resets the values on the backend.
+        fields = self.get_fields()
+        for prop_name in self.base_vars:
+            field = fields[prop_name]
+            if isinstance(field.default, ClientStorageBase) or (
+                isinstance(field.type_, type)
+                and issubclass(field.type_, ClientStorageBase)
+            ):
+                setattr(self, prop_name, field.default)
+
+        # Recursively reset the substates.
+        for substate in self.substates.values():
+            substate.reset()
+
     def get_substate(self, path: Sequence[str]) -> Optional[State]:
     def get_substate(self, path: Sequence[str]) -> Optional[State]:
         """Get the substate.
         """Get the substate.
 
 
@@ -1110,3 +1127,104 @@ def _convert_mutable_datatypes(
         )
         )
 
 
     return field_value
     return field_value
+
+
+class ClientStorageBase:
+    """Base class for client-side storage."""
+
+    def options(self) -> dict[str, Any]:
+        """Get the options for the storage.
+
+        Returns:
+            All set options for the storage (not None).
+        """
+        return {
+            format.to_camel_case(k): v for k, v in vars(self).items() if v is not None
+        }
+
+
+class Cookie(ClientStorageBase, str):
+    """Represents a state Var that is stored as a cookie in the browser."""
+
+    name: str | None
+    path: str
+    max_age: int | None
+    domain: str | None
+    secure: bool | None
+    same_site: str
+
+    def __new__(
+        cls,
+        object: Any = "",
+        encoding: str | None = None,
+        errors: str | None = None,
+        /,
+        name: str | None = None,
+        path: str = "/",
+        max_age: int | None = None,
+        domain: str | None = None,
+        secure: bool | None = None,
+        same_site: str = "lax",
+    ):
+        """Create a client-side Cookie (str).
+
+        Args:
+            object: The initial object.
+            encoding: The encoding to use.
+            errors: The error handling scheme to use.
+            name: The name of the cookie on the client side.
+            path: Cookie path. Use / as the path if the cookie should be accessible on all pages.
+            max_age: Relative max age of the cookie in seconds from when the client receives it.
+            domain: Domain for the cookie (sub.domain.com or .allsubdomains.com).
+            secure: Is the cookie only accessible through HTTPS?
+            same_site: Whether the cookie is sent with third party requests.
+                One of (true|false|none|lax|strict)
+
+        Returns:
+            The client-side Cookie object.
+
+        Note: expires (absolute Date) is not supported at this time.
+        """
+        if encoding or errors:
+            inst = super().__new__(cls, object, encoding or "utf-8", errors or "strict")
+        else:
+            inst = super().__new__(cls, object)
+        inst.name = name
+        inst.path = path
+        inst.max_age = max_age
+        inst.domain = domain
+        inst.secure = secure
+        inst.same_site = same_site
+        return inst
+
+
+class LocalStorage(ClientStorageBase, str):
+    """Represents a state Var that is stored in localStorage in the browser."""
+
+    name: str | None
+
+    def __new__(
+        cls,
+        object: Any = "",
+        encoding: str | None = None,
+        errors: str | None = None,
+        /,
+        name: str | None = None,
+    ) -> "LocalStorage":
+        """Create a client-side localStorage (str).
+
+        Args:
+            object: The initial object.
+            encoding: The encoding to use.
+            errors: The error handling scheme to use.
+            name: The name of the storage key on the client side.
+
+        Returns:
+            The client-side localStorage object.
+        """
+        if encoding or errors:
+            inst = super().__new__(cls, object, encoding or "utf-8", errors or "strict")
+        else:
+            inst = super().__new__(cls, object)
+        inst.name = name
+        return inst