From 16f0bef8b70d56c4a91fdb7d4bb50d00325f2b66 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 31 Jul 2026 20:45:59 +0530 Subject: [PATCH 1/3] Fix ClientStateVar late mount default sync --- news/6823.bugfix.md | 1 + reflex/experimental/client_state.py | 9 +++++- tests/units/test_client_state.py | 47 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 news/6823.bugfix.md create mode 100644 tests/units/test_client_state.py diff --git a/news/6823.bugfix.md b/news/6823.bugfix.md new file mode 100644 index 00000000000..f3e633df64e --- /dev/null +++ b/news/6823.bugfix.md @@ -0,0 +1 @@ +Fixed global `ClientStateVar` values getting stuck in late-mounted components when the shared value is pushed back to the default. diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index e24315b4734..5a56b3b977d 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -133,7 +133,6 @@ def create( setter_name = f"set{var_name.capitalize()}" hooks: dict[str, VarData | None] = { f"const {id_name} = useId()": None, - f"const [{var_name}, {setter_name}] = useState({default_var!s})": None, } imports = { "react": [ImportVar(tag="useState"), ImportVar(tag="useId")], @@ -144,6 +143,10 @@ def create( var_ref = _client_state_ref(var_name) var_dict_ref = _client_state_ref_dict(var_name) setter_dict_ref = _client_state_ref_dict(setter_name) + default_expr = "undefined" if default is NoValue else f"{default_var!s}" + hooks[ + f"const [{var_name}, {setter_name}] = useState(() => {var_ref!s} ?? {default_expr})" + ] = VarData.merge(default_var._var_data, var_ref._get_all_var_data()) func = ArgsFunctionOperationBuilder.create( args_names=(arg_name,), return_expr=Var("Array.prototype.forEach.call") @@ -176,6 +179,10 @@ def create( hooks[f"{setter_dict_ref!s}[{id_name}] = {setter_name}"] = ( setter_dict_ref._get_all_var_data() ) + else: + hooks[f"const [{var_name}, {setter_name}] = useState({default_var!s})"] = ( + None + ) return cls( _js_expr="null", _setter_name=setter_name, diff --git a/tests/units/test_client_state.py b/tests/units/test_client_state.py new file mode 100644 index 00000000000..577360fba17 --- /dev/null +++ b/tests/units/test_client_state.py @@ -0,0 +1,47 @@ +"""Tests for experimental client state vars.""" + +from reflex.experimental.client_state import ClientStateVar + + +def _hooks(client_state_var: ClientStateVar) -> tuple[str, ...]: + """Get all hook strings for a client state var. + + Returns: + The normalized hook strings. + """ + var_data = client_state_var._get_all_var_data() + assert var_data is not None + return var_data.hooks + + +def test_global_client_state_initializes_use_state_from_shared_ref(): + """Global client state should keep late-mounted components in sync.""" + flag = ClientStateVar.create("flag", default="") + + hooks = _hooks(flag) + + assert ( + """const [flag, setFlag] = useState(() => refs['_client_state_flag'] ?? "")""" + in hooks + ) + assert """const [flag, setFlag] = useState("")""" not in hooks + + +def test_global_client_state_without_default_uses_undefined_fallback(): + """Global client state without a default should still read the shared ref.""" + flag = ClientStateVar.create("flag") + + assert ( + "const [flag, setFlag] = useState(() => refs['_client_state_flag'] ?? undefined)" + in _hooks(flag) + ) + + +def test_local_client_state_keeps_plain_default_initializer(): + """Non-global client state should not read from the shared refs mirror.""" + flag = ClientStateVar.create("flag", default="", global_ref=False) + + hooks = _hooks(flag) + + assert """const [flag, setFlag] = useState("")""" in hooks + assert not any("refs['_client_state_flag']" in hook for hook in hooks) From da25092297ca26cd119976df249eb8a37d775012 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 31 Jul 2026 20:52:29 +0530 Subject: [PATCH 2/3] Preserve null ClientStateVar shared values --- reflex/experimental/client_state.py | 2 +- tests/units/test_client_state.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index 5a56b3b977d..913dfd5dbc4 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -145,7 +145,7 @@ def create( setter_dict_ref = _client_state_ref_dict(setter_name) default_expr = "undefined" if default is NoValue else f"{default_var!s}" hooks[ - f"const [{var_name}, {setter_name}] = useState(() => {var_ref!s} ?? {default_expr})" + f"const [{var_name}, {setter_name}] = useState(() => {var_ref!s} !== undefined ? {var_ref!s} : {default_expr})" ] = VarData.merge(default_var._var_data, var_ref._get_all_var_data()) func = ArgsFunctionOperationBuilder.create( args_names=(arg_name,), diff --git a/tests/units/test_client_state.py b/tests/units/test_client_state.py index 577360fba17..2e9acac3c91 100644 --- a/tests/units/test_client_state.py +++ b/tests/units/test_client_state.py @@ -21,18 +21,28 @@ def test_global_client_state_initializes_use_state_from_shared_ref(): hooks = _hooks(flag) assert ( - """const [flag, setFlag] = useState(() => refs['_client_state_flag'] ?? "")""" + """const [flag, setFlag] = useState(() => refs['_client_state_flag'] !== undefined ? refs['_client_state_flag'] : "")""" in hooks ) assert """const [flag, setFlag] = useState("")""" not in hooks +def test_global_client_state_preserves_null_shared_ref(): + """Global client state should not replace explicit null with the default.""" + flag = ClientStateVar.create("flag", default="idle") + + assert ( + """const [flag, setFlag] = useState(() => refs['_client_state_flag'] !== undefined ? refs['_client_state_flag'] : "idle")""" + in _hooks(flag) + ) + + def test_global_client_state_without_default_uses_undefined_fallback(): """Global client state without a default should still read the shared ref.""" flag = ClientStateVar.create("flag") assert ( - "const [flag, setFlag] = useState(() => refs['_client_state_flag'] ?? undefined)" + "const [flag, setFlag] = useState(() => refs['_client_state_flag'] !== undefined ? refs['_client_state_flag'] : undefined)" in _hooks(flag) ) From 45c088e225adce799c8fc493abb6422a6729fbdd Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 1 Aug 2026 08:58:50 +0530 Subject: [PATCH 3/3] Add ClientStateVar late mount integration repro --- .../tests_playwright/test_client_state.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/integration/tests_playwright/test_client_state.py diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py new file mode 100644 index 00000000000..906fd77a211 --- /dev/null +++ b/tests/integration/tests_playwright/test_client_state.py @@ -0,0 +1,84 @@ +"""Integration tests for experimental client state vars.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def ClientStateLateMountApp(): + """App reproducing late-mounted global ClientStateVar synchronization.""" + import asyncio + + import reflex as rx + from reflex.experimental import ClientStateVar + + flag = ClientStateVar.create("flag", default="") + + class State(rx.State): + mounted: bool = False + + @rx.event(background=True) + async def go(self): + async with self: + self.mounted = False + yield flag.push("busy") + await asyncio.sleep(0.2) + async with self: + self.mounted = True + await asyncio.sleep(0.2) + yield flag.push("") + + def index() -> rx.Component: + return rx.el.div( + rx.el.button("go", on_click=State.go, id="go"), + rx.el.div(flag.value, id="always"), + rx.cond(State.mounted, rx.el.div(flag.value, id="late")), + ) + + app = rx.App() + app.add_page(index, route="/") + + +@pytest.fixture(scope="module") +def client_state_late_mount_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Run the client state late-mount repro app. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + The running harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("client_state_late_mount_app"), + app_source=ClientStateLateMountApp, + ) as harness: + yield harness + + +def test_late_mounted_global_client_state_rerenders_on_default_push( + client_state_late_mount_app: AppHarness, page: Page +) -> None: + """A late-mounted consumer should update when the shared value returns to default. + + Args: + client_state_late_mount_app: Running app harness. + page: Playwright page. + """ + assert client_state_late_mount_app.frontend_url is not None + page.goto(client_state_late_mount_app.frontend_url) + + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_count(0) + + page.click("#go") + + expect(page.locator("#always")).to_have_text("busy") + expect(page.locator("#late")).to_have_text("busy") + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_text("")