Client-side storage / State integration (#1629)
This commit is contained in:
parent
4deffc2739
commit
9fbc75d84a
1
integration/__init__.py
Normal file
1
integration/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Package for integration tests."""
|
515
integration/test_client_storage.py
Normal file
515
integration/test_client_storage.py
Normal file
@ -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 == ""
|
@ -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
integration/utils.py
Normal file
173
integration/utils.py
Normal file
@ -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())
|
8
poetry.lock
generated
8
poetry.lock
generated
@ -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.11.2-py3-none-any.whl", hash = "sha256:98e72117b194b3fa9c69b48998f44bf7dd4152c7bd98544911a1753b9f03cc7d"},
|
||||||
{file = "selenium-4.10.0.tar.gz", hash = "sha256:871bf800c4934f745b909c8dfc7d15c65cf45bd2e943abd54451c810ada395e3"},
|
{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"
|
||||||
|
@ -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"
|
||||||
|
@ -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)),
|
||||||
|
]
|
@ -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]}>
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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]:
|
||||||
|
@ -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"
|
||||||
|
@ -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
reflex/state.py
118
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
|
||||||
|
Loading…
Reference in New Issue
Block a user