Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/wrapping-react/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ private = rx.client_state("") # scoped to the component tree using it
A **named** var resolves in one app-wide store, so any component can read and write it and
`push`, `retrieve`, `global_value` and `global_set` all work against it.

A name is an ASCII identifier: letters, digits and underscores, not starting with a digit.
Several modules may each declare the same named var, but only with the same default; two
different defaults fail the compile, since only one of them could take effect. Declaring the
var once in a shared module and importing it is simplest.

An **unnamed** var gets a compile-time name and is scoped: the outermost page or `@rx.memo`
whose body uses it owns it, so each instance of that component gets its own state, the way
React's `useState` does. Nothing outside the tree can address it, and a page's unnamed vars
Expand Down
1 change: 1 addition & 0 deletions news/+client-state-contract.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The deprecated `rx._x.client_state` and `reflex.experimental.client_state.ClientStateVar.create` apply the same `name` rule as `rx.client_state` (an ASCII identifier without `$`), and an app whose pages declare one client state name with two different defaults now fails to compile with an error naming both declarations.
Comment thread
masenf marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Tighten the `rx.client_state` contract. A `name` must be an ASCII identifier without `$` (letters, digits and underscores), and the error names the bad value. Declaring one name with two different defaults now fails the compile with both declaration sites instead of letting the first mounted one win, while identical declarations stay allowed. Names that differ only in case (`item`/`Item`) no longer break the build with a duplicate setter. The error for `push`/`retrieve`/`global_value`/`global_set` on an unnamed var now shows the right call, `rx.client_state(default, name="my_name")`, and points out a default that looks like a misplaced name. Compiled setters are now named `<name>RxClientStateSet` instead of `set<Name>`.
287 changes: 227 additions & 60 deletions packages/reflex-base/src/reflex_base/client_state.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -1201,4 +1201,8 @@ def _render_hooks(hooks: dict[str, VarData | None]) -> str:
Returns:
Rendered hooks code as string.
"""
# Imported here: reflex_base.client_state imports this module.
from reflex_base.client_state import _check_declarations

_check_declarations(hooks)
return "\n".join(hooks)
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,7 @@ class UnretrievableVarValueError(ReflexError):

class HybridPropertyError(ReflexError):
"""Raised when a hybrid property is misused while building its frontend var."""


class ClientStateConflictError(ReflexError, ValueError):
"""Raised when one client state name is declared with two different defaults."""
4 changes: 3 additions & 1 deletion reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from typing import TYPE_CHECKING, Any, overload

from reflex_base import constants, otel
from reflex_base.client_state import _collect_declarations
from reflex_base.components.component import Component, ComponentStyle
from reflex_base.config import get_config, reload_config
from reflex_base.context.base import BaseContext
Expand Down Expand Up @@ -1878,7 +1879,8 @@ def _compile(
"""
from reflex_base.utils.deterministic_hash import clear_hash_caches

with otel.compile_span(trigger, dry_run):
# One record of client state declarations spans the whole compile.
with otel.compile_span(trigger, dry_run), _collect_declarations():
ctx = TelemetryContext.start(trigger=trigger)
try:
if ctx is None:
Expand Down
30 changes: 18 additions & 12 deletions reflex/experimental/client_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from reflex_base.client_state import ClientStateVar as _ClientStateVar
from reflex_base.client_state import NoValue as NoValue
from reflex_base.client_state import _generate_name
from reflex_base.client_state import _generate_name, _validate_name
from reflex_base.utils import console

__all__ = ["ClientStateVar", "NoValue", "client_state"]
Expand All @@ -36,27 +36,33 @@ def _deprecate(feature_name: str) -> None:
)


def _legacy_name(var_name: str | None, global_ref: bool | Any) -> str | None:
"""Map the legacy ``(var_name, global_ref)`` pair onto the naming rules.
def _legacy_create(
cls: type[_ClientStateVar],
var_name: str | None,
default: Any,
global_ref: bool | Any,
) -> _ClientStateVar:
"""Create a var from the legacy ``(var_name, default, global_ref)`` arguments.

``global_ref`` defaults to true and an omitted ``var_name`` is generated, so
a legacy var is global unless ``global_ref=False`` is passed, in which case
its name is dropped and the var is scoped.

Args:
cls: The class to instantiate.
var_name: The legacy name.
default: The default value of the variable.
global_ref: The legacy global flag, or ``NoValue`` when not given.

Returns:
The name to create the var with: ``None`` scopes it, anything else
makes it global.
The client state var.
"""
if global_ref is not NoValue and not global_ref:
return None
if var_name is None:
# Drawn from the same generator as scoped names, so the two never collide.
return _generate_name()
return var_name
return cls._create(default, _generate_name(), is_global=False)
# A generated name is drawn from the same generator as scoped names, so the
# two never collide; it is global only because the legacy API made it so.
name = _generate_name() if var_name is None else _validate_name(var_name, "name")
return cls._create(default, name, is_global=True)


class ClientStateVar(_ClientStateVar):
Expand Down Expand Up @@ -86,7 +92,7 @@ def create( # pyright: ignore [reportIncompatibleMethodOverride]
The client state var.
"""
_deprecate("ClientStateVar.create")
return super().create(default, name=_legacy_name(var_name, global_ref))
return _legacy_create(cls, var_name, default, global_ref)


def client_state(
Expand All @@ -107,4 +113,4 @@ def client_state(
The client state var.
"""
_deprecate("rx._x.client_state")
return _ClientStateVar.create(default, name=_legacy_name(var_name, global_ref))
return _legacy_create(_ClientStateVar, var_name, default, global_ref)
4 changes: 2 additions & 2 deletions tests/units/compiler/test_memoize_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1881,7 +1881,7 @@ def page() -> Component:
code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx"
)

assert "setCounter(42)" in memo_code, (
assert "counterRxClientStateSet(42)" in memo_code, (
"Expected the memo body to call the client-state setter.\n"
f"Memo code snippet: {memo_code[:2000]}"
)
Expand Down Expand Up @@ -1945,7 +1945,7 @@ def page() -> Component:
)
# A global var passes ``true`` as the third argument; a scoped var omits it.
hook = re.search(
r'const \[[\w$]+, (set[\w$]+)\] = useClientState\("a", "([\w$]+)"(, true)?\)',
r'const \[[\w$]+, ([\w$]+RxClientStateSet)\] = useClientState\("a", "([\w$]+)"(, true)?\)',
button_memo,
)
assert hook is not None, button_memo
Expand Down
18 changes: 18 additions & 0 deletions tests/units/experimental/test_client_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,21 @@ def test_legacy_unnamed_var_stays_global(legacy_call: str) -> None:
cs.push("x")
cs.retrieve()
assert cs.global_value is not None


def test_legacy_name_follows_the_name_rules() -> None:
"""The deprecated factory validates names like the new one."""
with pytest.raises(ValueError, match="letter or underscore"):
rx._x.client_state("a$b", 0)


def test_legacy_create_with_only_a_name_still_names_the_var(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The old class keeps reading a lone positional string as the name."""
from reflex.experimental.client_state import ClientStateVar

cs = ClientStateVar.create("user_input_legacy")
assert cs._state_name == "user_input_legacy"
assert cs._is_global
assert "ClientStateVar.create" in caplog.text
Loading
Loading