From 24ff29f74d89ff89828442e7e7b4e6bdb08f3ad2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 26 Nov 2024 14:04:36 -0800 Subject: [PATCH 1/5] bump to 0.6.7dev1 for further development (#4434) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 980c16f97..619c4ff47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "reflex" -version = "0.6.6dev1" +version = "0.6.7dev1" description = "Web apps in pure Python." license = "Apache-2.0" authors = [ From 39cdce6960fef0cdf73b76f6b99f3f174d83059d Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 28 Nov 2024 04:56:41 -0800 Subject: [PATCH 2/5] [HOS-333] Send a "reload" message to the frontend after state expiry (#4442) * Unit test updates * test_client_storage: simulate backend state expiry * [HOS-333] Send a "reload" message to the frontend after state expiry 1. a state instance expires on the backing store 2. frontend attempts to process an event against the expired token and gets a fresh instance of the state without router_data set 3. backend sends a "reload" message on the websocket containing the event and immediately stops processing 4. in response to the "reload" message, frontend sends [hydrate, update client storage, on_load, ] This allows the frontend and backend to re-syncronize on the state of the app before continuing to process regular events. If the event in (2) is a special hydrate event, then it is processed normally by the middleware and the "reload" logic is skipped since this indicates an initial load or a browser refresh. * unit tests working with redis --- reflex/.templates/web/utils/state.js | 4 + reflex/app.py | 16 ++++ reflex/state.py | 3 + tests/integration/test_client_storage.py | 113 ++++++++++++++++++++++- tests/units/test_app.py | 8 +- tests/units/test_state.py | 12 ++- 6 files changed, 150 insertions(+), 6 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index e14c669f5..f6541c7ae 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -454,6 +454,10 @@ export const connect = async ( queueEvents(update.events, socket); } }); + socket.current.on("reload", async (event) => { + event_processing = false; + queueEvents([...initialEvents(), JSON5.parse(event)], socket); + }) document.addEventListener("visibilitychange", checkVisibility); }; diff --git a/reflex/app.py b/reflex/app.py index fc8efb420..cdf21aa35 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -73,6 +73,7 @@ from reflex.event import ( EventSpec, EventType, IndividualEventType, + get_hydrate_event, window_alert, ) from reflex.model import Model, get_db_status @@ -1259,6 +1260,21 @@ async def process( ) # Get the state for the session exclusively. async with app.state_manager.modify_state(event.substate_token) as state: + # When this is a brand new instance of the state, signal the + # frontend to reload before processing it. + if ( + not state.router_data + and event.name != get_hydrate_event(state) + and app.event_namespace is not None + ): + await asyncio.create_task( + app.event_namespace.emit( + "reload", + data=format.json_dumps(event), + to=sid, + ) + ) + return # re-assign only when the value is different if state.router_data != router_data: # assignment will recurse into substates and force recalculation of diff --git a/reflex/state.py b/reflex/state.py index 127fe98cc..55f29cf45 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1959,6 +1959,9 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow): if var in self.base_vars or var in self._backend_vars: self._was_touched = True break + if var == constants.ROUTER_DATA and self.parent_state is None: + self._was_touched = True + break def _get_was_touched(self) -> bool: """Check current dirty_vars and flag to determine if state instance was modified. diff --git a/tests/integration/test_client_storage.py b/tests/integration/test_client_storage.py index e8c95ab71..236d3e14e 100644 --- a/tests/integration/test_client_storage.py +++ b/tests/integration/test_client_storage.py @@ -10,6 +10,13 @@ from selenium.webdriver import Firefox from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver +from reflex.state import ( + State, + StateManagerDisk, + StateManagerMemory, + StateManagerRedis, + _substate_key, +) from reflex.testing import AppHarness from . import utils @@ -74,7 +81,7 @@ def ClientSide(): return rx.fragment( rx.input( value=ClientSideState.router.session.client_token, - is_read_only=True, + read_only=True, id="token", ), rx.input( @@ -604,6 +611,110 @@ async def test_client_side_state( assert s2.text == "s2 value" assert s3.text == "s3 value" + # Simulate state expiration + if isinstance(client_side.state_manager, StateManagerRedis): + await client_side.state_manager.redis.delete( + _substate_key(token, State.get_full_name()) + ) + await client_side.state_manager.redis.delete(_substate_key(token, state_name)) + await client_side.state_manager.redis.delete( + _substate_key(token, sub_state_name) + ) + await client_side.state_manager.redis.delete( + _substate_key(token, sub_sub_state_name) + ) + elif isinstance(client_side.state_manager, (StateManagerMemory, StateManagerDisk)): + del client_side.state_manager.states[token] + if isinstance(client_side.state_manager, StateManagerDisk): + client_side.state_manager.token_expiration = 0 + client_side.state_manager._purge_expired_states() + + # Ensure the state is gone (not hydrated) + async def poll_for_not_hydrated(): + state = await client_side.get_state(_substate_key(token or "", state_name)) + return not state.is_hydrated + + assert await AppHarness._poll_for_async(poll_for_not_hydrated) + + # Trigger event to get a new instance of the state since the old was expired. + state_var_input = driver.find_element(By.ID, "state_var") + state_var_input.send_keys("re-triggering") + + # 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") + s1 = driver.find_element(By.ID, "s1") + s2 = driver.find_element(By.ID, "s2") + s3 = driver.find_element(By.ID, "s3") + c1s = driver.find_element(By.ID, "c1s") + l1s = driver.find_element(By.ID, "l1s") + s1s = driver.find_element(By.ID, "s1s") + + 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 s1.text == "s1 value" + assert s2.text == "s2 value" + assert s3.text == "s3 value" + assert c1s.text == "c1s value" + assert l1s.text == "l1s value" + assert s1s.text == "s1s value" + + # Get the backend state and ensure the values are still set + async def get_sub_state(): + root_state = await client_side.get_state( + _substate_key(token or "", sub_state_name) + ) + state = root_state.substates[client_side.get_state_name("_client_side_state")] + sub_state = state.substates[ + client_side.get_state_name("_client_side_sub_state") + ] + return sub_state + + async def poll_for_c1_set(): + sub_state = await get_sub_state() + return sub_state.c1 == "c1 value" + + assert await AppHarness._poll_for_async(poll_for_c1_set) + sub_state = await get_sub_state() + assert sub_state.c1 == "c1 value" + assert sub_state.c2 == "c2 value" + assert sub_state.c3 == "" + assert sub_state.c4 == "c4 value" + assert sub_state.c5 == "c5 value" + assert sub_state.c6 == "c6 value" + assert sub_state.c7 == "c7 value" + assert sub_state.l1 == "l1 value" + assert sub_state.l2 == "l2 value" + assert sub_state.l3 == "l3 value" + assert sub_state.l4 == "l4 value" + assert sub_state.s1 == "s1 value" + assert sub_state.s2 == "s2 value" + assert sub_state.s3 == "s3 value" + sub_sub_state = sub_state.substates[ + client_side.get_state_name("_client_side_sub_sub_state") + ] + assert sub_sub_state.c1s == "c1s value" + assert sub_sub_state.l1s == "l1s value" + assert sub_sub_state.s1s == "s1s value" + # clear the cookie jar and local storage, ensure state reset to default driver.delete_all_cookies() local_storage.clear() diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 5d3aee6c7..216d36f62 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1007,8 +1007,9 @@ async def test_dynamic_route_var_route_change_completed_on_load( substate_token = _substate_key(token, DynamicState) sid = "mock_sid" client_ip = "127.0.0.1" - state = await app.state_manager.get_state(substate_token) - assert state.dynamic == "" + async with app.state_manager.modify_state(substate_token) as state: + state.router_data = {"simulate": "hydrated"} + assert state.dynamic == "" exp_vals = ["foo", "foobar", "baz"] def _event(name, val, **kwargs): @@ -1180,6 +1181,7 @@ async def test_process_events(mocker, token: str): "ip": "127.0.0.1", } app = App(state=GenState) + mocker.patch.object(app, "_postprocess", AsyncMock()) event = Event( token=token, @@ -1187,6 +1189,8 @@ async def test_process_events(mocker, token: str): payload={"c": 5}, router_data=router_data, ) + async with app.state_manager.modify_state(event.substate_token) as state: + state.router_data = {"simulate": "hydrated"} async for _update in process(app, event, "mock_sid", {}, "127.0.0.1"): pass diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 45c021bd8..8e61b8dae 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -1982,6 +1982,10 @@ class BackgroundTaskState(BaseState): order: List[str] = [] dict_list: Dict[str, List[int]] = {"foo": [1, 2, 3]} + def __init__(self, **kwargs): # noqa: D107 + super().__init__(**kwargs) + self.router_data = {"simulate": "hydrate"} + @rx.var def computed_order(self) -> List[str]: """Get the order as a computed var. @@ -2732,7 +2736,7 @@ def test_set_base_field_via_setter(): assert "c2" in bfss.dirty_vars -def exp_is_hydrated(state: State, is_hydrated: bool = True) -> Dict[str, Any]: +def exp_is_hydrated(state: BaseState, is_hydrated: bool = True) -> Dict[str, Any]: """Expected IS_HYDRATED delta that would be emitted by HydrateMiddleware. Args: @@ -2811,7 +2815,8 @@ async def test_preprocess(app_module_mock, token, test_state, expected, mocker): app = app_module_mock.app = App( state=State, load_events={"index": [test_state.test_handler]} ) - state = State() + async with app.state_manager.modify_state(_substate_key(token, State)) as state: + state.router_data = {"simulate": "hydrate"} updates = [] async for update in rx.app.process( @@ -2858,7 +2863,8 @@ async def test_preprocess_multiple_load_events(app_module_mock, token, mocker): state=State, load_events={"index": [OnLoadState.test_handler, OnLoadState.test_handler]}, ) - state = State() + async with app.state_manager.modify_state(_substate_key(token, State)) as state: + state.router_data = {"simulate": "hydrate"} updates = [] async for update in rx.app.process( From a320d062fb739235642c95fc4907f7d5f9531e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Brand=C3=A9ho?= Date: Mon, 2 Dec 2024 09:20:33 -0800 Subject: [PATCH 3/5] enable css props via wrapperStyle for recharts components (#4447) --- reflex/components/recharts/recharts.py | 16 ++-------------- reflex/components/recharts/recharts.pyi | 1 - 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/reflex/components/recharts/recharts.py b/reflex/components/recharts/recharts.py index a0d683f72..b5a4ed113 100644 --- a/reflex/components/recharts/recharts.py +++ b/reflex/components/recharts/recharts.py @@ -3,7 +3,6 @@ from typing import Dict, Literal from reflex.components.component import Component, MemoizationLeaf, NoSSRComponent -from reflex.utils import console class Recharts(Component): @@ -11,19 +10,8 @@ class Recharts(Component): library = "recharts@2.13.0" - def render(self) -> Dict: - """Render the tag. - - Returns: - The rendered tag. - """ - tag = super().render() - if any(p.startswith("css") for p in tag["props"]): - console.warn( - f"CSS props do not work for {self.__class__.__name__}. Consult docs to style it with its own prop." - ) - tag["props"] = [p for p in tag["props"] if not p.startswith("css")] - return tag + def _get_style(self) -> Dict: + return {"wrapperStyle": self.style} class RechartsCharts(NoSSRComponent, MemoizationLeaf): diff --git a/reflex/components/recharts/recharts.pyi b/reflex/components/recharts/recharts.pyi index 10e1b96c1..65e65bce1 100644 --- a/reflex/components/recharts/recharts.pyi +++ b/reflex/components/recharts/recharts.pyi @@ -11,7 +11,6 @@ from reflex.style import Style from reflex.vars.base import Var class Recharts(Component): - def render(self) -> Dict: ... @overload @classmethod def create( # type: ignore From 99d1b5fbdfaa05cfac3f61fdc4f4c991178567d4 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 2 Dec 2024 16:29:06 -0800 Subject: [PATCH 4/5] rx.upload must include _var_data from props (#4463) * rx.upload must include _var_data from props str-casting the dropzone arguments removed any VarData they depended on, like the state context. update test_upload to include passing a prop from a state var * Handle large payload delta from upload event handler Fix update chunk chaining logic; try/catch wasn't catching errors from the async inner function. --- reflex/.templates/web/utils/state.js | 39 ++++++++++++++++------------ reflex/components/core/upload.py | 13 ++++++---- tests/integration/test_upload.py | 10 ++++++- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index f6541c7ae..622f171ad 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -457,7 +457,7 @@ export const connect = async ( socket.current.on("reload", async (event) => { event_processing = false; queueEvents([...initialEvents(), JSON5.parse(event)], socket); - }) + }); document.addEventListener("visibilitychange", checkVisibility); }; @@ -490,23 +490,30 @@ export const uploadFiles = async ( return false; } + // Track how many partial updates have been processed for this upload. let resp_idx = 0; const eventHandler = (progressEvent) => { - // handle any delta / event streamed from the upload event handler + const event_callbacks = socket._callbacks.$event; + // Whenever called, responseText will contain the entire response so far. const chunks = progressEvent.event.target.responseText.trim().split("\n"); + // So only process _new_ chunks beyond resp_idx. chunks.slice(resp_idx).map((chunk) => { - try { - socket._callbacks.$event.map((f) => { - f(chunk); - }); - resp_idx += 1; - } catch (e) { - if (progressEvent.progress === 1) { - // Chunk may be incomplete, so only report errors when full response is available. - console.log("Error parsing chunk", chunk, e); - } - return; - } + event_callbacks.map((f, ix) => { + f(chunk) + .then(() => { + if (ix === event_callbacks.length - 1) { + // Mark this chunk as processed. + resp_idx += 1; + } + }) + .catch((e) => { + if (progressEvent.progress === 1) { + // Chunk may be incomplete, so only report errors when full response is available. + console.log("Error parsing chunk", chunk, e); + } + return; + }); + }); }); }; @@ -711,7 +718,7 @@ export const useEventLoop = ( const combined_name = events.map((e) => e.name).join("+++"); if (event_actions?.temporal) { if (!socket.current || !socket.current.connected) { - return; // don't queue when the backend is not connected + return; // don't queue when the backend is not connected } } if (event_actions?.throttle) { @@ -852,7 +859,7 @@ export const useEventLoop = ( if (router.components[router.pathname].error) { delete router.components[router.pathname].error; } - } + }; router.events.on("routeChangeStart", change_start); router.events.on("routeChangeComplete", change_complete); router.events.on("routeChangeError", change_error); diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index 33dfae40f..87488d98a 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -293,13 +293,15 @@ class Upload(MemoizationLeaf): format.to_camel_case(key): value for key, value in upload_props.items() } - use_dropzone_arguments = { - "onDrop": event_var, - **upload_props, - } + use_dropzone_arguments = Var.create( + { + "onDrop": event_var, + **upload_props, + } + ) left_side = f"const {{getRootProps: {root_props_unique_name}, getInputProps: {input_props_unique_name}}} " - right_side = f"useDropzone({str(Var.create(use_dropzone_arguments))})" + right_side = f"useDropzone({str(use_dropzone_arguments)})" var_data = VarData.merge( VarData( @@ -307,6 +309,7 @@ class Upload(MemoizationLeaf): hooks={Hooks.EVENTS: None}, ), event_var._get_all_var_data(), + use_dropzone_arguments._get_all_var_data(), VarData( hooks={ callback_str: None, diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py index fe8ebb4d7..b7f14b03d 100644 --- a/tests/integration/test_upload.py +++ b/tests/integration/test_upload.py @@ -19,10 +19,14 @@ def UploadFile(): import reflex as rx + LARGE_DATA = "DUMMY" * 1024 * 512 + class UploadState(rx.State): _file_data: Dict[str, str] = {} event_order: List[str] = [] progress_dicts: List[dict] = [] + disabled: bool = False + large_data: str = "" async def handle_upload(self, files: List[rx.UploadFile]): for file in files: @@ -33,6 +37,7 @@ def UploadFile(): for file in files: upload_data = await file.read() self._file_data[file.filename or ""] = upload_data.decode("utf-8") + self.large_data = LARGE_DATA yield UploadState.chain_event def upload_progress(self, progress): @@ -41,13 +46,15 @@ def UploadFile(): self.progress_dicts.append(progress) def chain_event(self): + assert self.large_data == LARGE_DATA + self.large_data = "" self.event_order.append("chain_event") def index(): return rx.vstack( rx.input( value=UploadState.router.session.client_token, - is_read_only=True, + read_only=True, id="token", ), rx.heading("Default Upload"), @@ -56,6 +63,7 @@ def UploadFile(): rx.button("Select File"), rx.text("Drag and drop files here or click to select files"), ), + disabled=UploadState.disabled, ), rx.button( "Upload", From c721227a062ed6f977477e435d37b3fa5b0be509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Brand=C3=A9ho?= Date: Mon, 2 Dec 2024 16:31:32 -0800 Subject: [PATCH 5/5] add default value for text area (#4462) * add default_value prop in text_area * also support it for el.textarea --- reflex/components/el/elements/forms.py | 3 +++ reflex/components/el/elements/forms.pyi | 2 ++ reflex/components/radix/themes/components/text_area.py | 3 +++ reflex/components/radix/themes/components/text_area.pyi | 2 ++ 4 files changed, 10 insertions(+) diff --git a/reflex/components/el/elements/forms.py b/reflex/components/el/elements/forms.py index 7a94a9c2d..56dab5c7f 100644 --- a/reflex/components/el/elements/forms.py +++ b/reflex/components/el/elements/forms.py @@ -570,6 +570,9 @@ class Textarea(BaseHTML): # Visible width of the text control, in average character widths cols: Var[Union[str, int, bool]] + # The default value of the textarea when initially rendered + default_value: Var[str] + # Name part of the textarea to submit in 'dir' and 'name' pair when form is submitted dirname: Var[Union[str, int, bool]] diff --git a/reflex/components/el/elements/forms.pyi b/reflex/components/el/elements/forms.pyi index a32eb8c5d..e2d659338 100644 --- a/reflex/components/el/elements/forms.pyi +++ b/reflex/components/el/elements/forms.pyi @@ -1350,6 +1350,7 @@ class Textarea(BaseHTML): auto_focus: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, auto_height: Optional[Union[Var[bool], bool]] = None, cols: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + default_value: Optional[Union[Var[str], str]] = None, dirname: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, disabled: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, enter_key_submit: Optional[Union[Var[bool], bool]] = None, @@ -1439,6 +1440,7 @@ class Textarea(BaseHTML): auto_focus: Automatically focuses the textarea when the page loads auto_height: Automatically fit the content height to the text (use min-height with this prop) cols: Visible width of the text control, in average character widths + default_value: The default value of the textarea when initially rendered dirname: Name part of the textarea to submit in 'dir' and 'name' pair when form is submitted disabled: Disables the textarea enter_key_submit: Enter key submits form (shift-enter adds new line) diff --git a/reflex/components/radix/themes/components/text_area.py b/reflex/components/radix/themes/components/text_area.py index 87f56e911..83fa8a593 100644 --- a/reflex/components/radix/themes/components/text_area.py +++ b/reflex/components/radix/themes/components/text_area.py @@ -41,6 +41,9 @@ class TextArea(RadixThemesComponent, elements.Textarea): # Automatically focuses the textarea when the page loads auto_focus: Var[bool] + # The default value of the textarea when initially rendered + default_value: Var[str] + # Name part of the textarea to submit in 'dir' and 'name' pair when form is submitted dirname: Var[str] diff --git a/reflex/components/radix/themes/components/text_area.pyi b/reflex/components/radix/themes/components/text_area.pyi index 196346cf9..63d474842 100644 --- a/reflex/components/radix/themes/components/text_area.pyi +++ b/reflex/components/radix/themes/components/text_area.pyi @@ -123,6 +123,7 @@ class TextArea(RadixThemesComponent, elements.Textarea): ] = None, auto_complete: Optional[Union[Var[bool], bool]] = None, auto_focus: Optional[Union[Var[bool], bool]] = None, + default_value: Optional[Union[Var[str], str]] = None, dirname: Optional[Union[Var[str], str]] = None, disabled: Optional[Union[Var[bool], bool]] = None, form: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, @@ -217,6 +218,7 @@ class TextArea(RadixThemesComponent, elements.Textarea): radius: The radius of the text area: "none" | "small" | "medium" | "large" | "full" auto_complete: Whether the form control should have autocomplete enabled auto_focus: Automatically focuses the textarea when the page loads + default_value: The default value of the textarea when initially rendered dirname: Name part of the textarea to submit in 'dir' and 'name' pair when form is submitted disabled: Disables the textarea form: Associates the textarea with a form (by id)