Skip to content

Commit 6b4c5fa

Browse files
unique per sibling
1 parent db97e3a commit 6b4c5fa

4 files changed

Lines changed: 176 additions & 106 deletions

File tree

reflex/reflex.py

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -992,36 +992,42 @@ def print_state_tree(
992992
)
993993
@click.argument("minified_path")
994994
def state_lookup(output_json: bool, minified_path: str):
995-
"""Lookup a state by its minified path (e.g., 'a.bU')."""
996-
from reflex.state import _minified_name_to_int, _state_id_registry
995+
"""Lookup a state by its minified path (e.g., 'a.bU').
996+
997+
Walks the state tree from the root to resolve each segment.
998+
"""
999+
from reflex.state import State
9971000
from reflex.utils import prerequisites
9981001

9991002
# Load the user's app to register all state classes
10001003
prerequisites.get_app()
10011004

1002-
# Parse the dotted path
1003-
parts = minified_path.split(".")
1005+
try:
1006+
State.get_class_substate(minified_path)
1007+
except ValueError:
1008+
msg = f"No state found for path: {minified_path}"
1009+
console.error(msg)
1010+
raise ValueError(msg) from None
10041011

1005-
# Resolve each part
1012+
# Build info for each ancestor segment
1013+
parts = minified_path.split(".")
10061014
result_parts = []
1007-
for part in parts:
1008-
try:
1009-
state_id = _minified_name_to_int(part)
1010-
except ValueError as err:
1011-
console.error(f"Invalid minified name: {part}")
1012-
raise SystemExit(1) from err
1013-
1014-
state_cls = _state_id_registry.get(state_id)
1015-
if state_cls is None:
1016-
console.error(f"No state registered with state_id={state_id}")
1017-
raise SystemExit(1)
1018-
1015+
current = State
1016+
result_parts.append({
1017+
"minified": parts[0],
1018+
"state_id": current._state_id,
1019+
"module": current.__module__,
1020+
"class": current.__name__,
1021+
"full_name": current.get_full_name(),
1022+
})
1023+
for part in parts[1:]:
1024+
current = current.get_class_substate(part)
10191025
result_parts.append({
10201026
"minified": part,
1021-
"state_id": state_id,
1022-
"module": state_cls.__module__,
1023-
"class": state_cls.__name__,
1024-
"full_name": state_cls.get_full_name(),
1027+
"state_id": current._state_id,
1028+
"module": current.__module__,
1029+
"class": current.__name__,
1030+
"full_name": current.get_full_name(),
10251031
})
10261032

10271033
if output_json:
@@ -1034,31 +1040,83 @@ def state_lookup(output_json: bool, minified_path: str):
10341040
console.log(f"{info['module']}.{info['class']}")
10351041

10361042

1043+
def _resolve_parent_state(parent: str):
1044+
"""Resolve a parent argument to a state class.
1045+
1046+
Accepts either a state path (minified like 'a.b' or full name) or a class
1047+
name (e.g., 'State', 'MySubState'). Tries path resolution first via
1048+
get_class_substate, then falls back to searching by class name.
1049+
1050+
Args:
1051+
parent: Class name or state path identifying the parent state.
1052+
1053+
Returns:
1054+
The resolved state class.
1055+
1056+
Raises:
1057+
SystemExit: If the parent cannot be resolved.
1058+
"""
1059+
from reflex.state import BaseState, State
1060+
1061+
# Try as a state path (minified or full name)
1062+
try:
1063+
return State.get_class_substate(parent)
1064+
except ValueError:
1065+
pass
1066+
1067+
# Fall back to searching by class name
1068+
def _find_by_name(cls: type[BaseState], name: str) -> type[BaseState] | None:
1069+
if cls.__name__ == name:
1070+
return cls
1071+
for child in cls.class_subclasses:
1072+
result = _find_by_name(child, name)
1073+
if result is not None:
1074+
return result
1075+
return None
1076+
1077+
result = _find_by_name(State, parent)
1078+
if result is not None:
1079+
return result
1080+
1081+
console.error(f"No state found matching '{parent}'")
1082+
raise SystemExit(1)
1083+
1084+
10371085
@cli.command(name="state-next-id")
10381086
@loglevel_option
10391087
@click.option(
10401088
"--after-max",
10411089
is_flag=True,
10421090
help="Return max(state_id) + 1 instead of first gap.",
10431091
)
1044-
def state_next_id(after_max: bool):
1045-
"""Print the next available state_id."""
1046-
from reflex.state import _state_id_registry
1092+
@click.argument("parent")
1093+
def state_next_id(after_max: bool, parent: str):
1094+
"""Print the next available state_id under PARENT.
1095+
1096+
PARENT can be a class name (e.g., 'State', 'MySubState') or a
1097+
minified path (e.g., 'a', 'a.b'). Auto-determined from input.
1098+
"""
10471099
from reflex.utils import prerequisites
10481100

10491101
# Load the user's app to register all state classes
10501102
prerequisites.get_app()
10511103

1052-
if not _state_id_registry:
1104+
parent_cls = _resolve_parent_state(parent)
1105+
1106+
# Collect sibling state_ids under the parent
1107+
used_ids = {
1108+
child._state_id
1109+
for child in parent_cls.class_subclasses
1110+
if child._state_id is not None
1111+
}
1112+
1113+
if not used_ids:
10531114
console.log("0")
10541115
return
10551116

10561117
if after_max:
1057-
# Return max + 1
1058-
next_id = max(_state_id_registry.keys()) + 1
1118+
next_id = max(used_ids) + 1
10591119
else:
1060-
# Find first gap starting from 0
1061-
used_ids = set(_state_id_registry.keys())
10621120
next_id = 0
10631121
while next_id in used_ids:
10641122
next_id += 1

reflex/state.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@
104104
# For BaseState.get_var_value
105105
VAR_TYPE = TypeVar("VAR_TYPE")
106106

107-
# Global registry: state_id -> state class (for duplicate detection)
108-
_state_id_registry: dict[int, type[BaseState]] = {}
109107

110108
# Characters used for minified names (valid JS identifiers)
111109
MINIFIED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_"
@@ -607,22 +605,6 @@ def __init_subclass__(
607605
# Store state_id as class variable (only for non-mixins)
608606
cls._state_id = state_id
609607

610-
# Validate state_id if provided (check for duplicates)
611-
if state_id is not None:
612-
if state_id in _state_id_registry:
613-
existing_cls = _state_id_registry[state_id]
614-
# Allow re-registration if it's the same class (e.g., module reload)
615-
existing_key = f"{existing_cls.__module__}.{existing_cls.__name__}"
616-
new_key = f"{cls.__module__}.{cls.__name__}"
617-
if existing_key != new_key:
618-
msg = (
619-
f"Duplicate state_id={state_id}. Already used by "
620-
f"'{existing_cls.__module__}.{existing_cls.__name__}', "
621-
f"cannot be reused by '{cls.__module__}.{cls.__name__}'."
622-
)
623-
raise StateValueError(msg)
624-
_state_id_registry[state_id] = cls
625-
626608
# Handle locally-defined states for pickling.
627609
if "<locals>" in cls.__qualname__:
628610
cls._handle_local_def()
@@ -649,6 +631,21 @@ def __init_subclass__(
649631
cls.inherited_vars = parent_state.vars
650632
cls.inherited_backend_vars = parent_state.backend_vars
651633

634+
# Check for duplicate state_id among siblings.
635+
if state_id is not None:
636+
for sibling in parent_state.class_subclasses:
637+
if sibling._state_id is not None and sibling._state_id == state_id:
638+
# Allow re-registration of the same class (e.g., module reload)
639+
existing_key = f"{sibling.__module__}.{sibling.__name__}"
640+
new_key = f"{cls.__module__}.{cls.__name__}"
641+
if existing_key != new_key:
642+
msg = (
643+
f"Duplicate state_id={state_id} among siblings of "
644+
f"'{parent_state.__name__}': already used by "
645+
f"'{sibling.__name__}', cannot be reused by '{cls.__name__}'."
646+
)
647+
raise StateValueError(msg)
648+
652649
# Check if another substate class with the same name has already been defined.
653650
if cls.get_name() in {c.get_name() for c in parent_state.class_subclasses}:
654651
# This should not happen, since we have added module prefix to state names in #3214
@@ -2746,7 +2743,7 @@ def wrapper() -> Component:
27462743
LAST_RELOADED_KEY = "reflex_last_reloaded_on_error"
27472744

27482745

2749-
class FrontendEventExceptionState(State, state_id=1):
2746+
class FrontendEventExceptionState(State, state_id=0):
27502747
"""Substate for handling frontend exceptions."""
27512748

27522749
# If the frontend error message contains any of these strings, automatically reload the page.
@@ -2799,7 +2796,7 @@ def handle_frontend_exception(
27992796
)
28002797

28012798

2802-
class UpdateVarsInternalState(State, state_id=2):
2799+
class UpdateVarsInternalState(State, state_id=1):
28032800
"""Substate for handling internal state var updates."""
28042801

28052802
async def update_vars_internal(self, vars: dict[str, Any]) -> None:
@@ -2823,7 +2820,7 @@ async def update_vars_internal(self, vars: dict[str, Any]) -> None:
28232820
setattr(var_state, var_name, value)
28242821

28252822

2826-
class OnLoadInternalState(State, state_id=3):
2823+
class OnLoadInternalState(State, state_id=2):
28272824
"""Substate for handling on_load event enumeration.
28282825
28292826
This is a separate substate to avoid deserializing the entire state tree for every page navigation.

tests/integration/test_minification.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from selenium.webdriver.common.by import By
1212

1313
from reflex.environment import MinifyMode, environment
14-
from reflex.state import _int_to_minified_name, _state_id_registry
14+
from reflex.state import _int_to_minified_name
1515
from reflex.testing import AppHarness
1616

1717
if TYPE_CHECKING:
@@ -98,14 +98,6 @@ def index() -> rx.Component:
9898
app.add_page(index)
9999

100100

101-
@pytest.fixture(autouse=True)
102-
def reset_state_registry():
103-
"""Reset the state_id registry before and after each test."""
104-
_state_id_registry.clear()
105-
yield
106-
_state_id_registry.clear()
107-
108-
109101
@pytest.fixture
110102
def minify_disabled_app(
111103
app_harness_env: type[AppHarness],

0 commit comments

Comments
 (0)