Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6823.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed global `ClientStateVar` values getting stuck in late-mounted components when the shared value is pushed back to the default.
9 changes: 8 additions & 1 deletion reflex/experimental/client_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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} !== undefined ? {var_ref!s} : {default_expr})"
] = VarData.merge(default_var._var_data, var_ref._get_all_var_data())
Comment thread
harsh21234i marked this conversation as resolved.
func = ArgsFunctionOperationBuilder.create(
args_names=(arg_name,),
return_expr=Var("Array.prototype.forEach.call")
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions tests/integration/tests_playwright/test_client_state.py
Original file line number Diff line number Diff line change
@@ -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("")
57 changes: 57 additions & 0 deletions tests/units/test_client_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""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'] !== 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 ? 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)
Loading