From 2656ee5f1cf97225628acd861182dcb64081cbb5 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 24 Jan 2026 20:43:29 +0100 Subject: [PATCH 01/28] feat: Add explicit state ID minification for states --- reflex/environment.py | 12 + reflex/state.py | 86 +++++- reflex/vars/base.py | 5 + tests/integration/test_state_minification.py | 290 +++++++++++++++++++ tests/units/test_state_minification.py | 176 +++++++++++ uploaded_files/test.txt | 1 + 6 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_state_minification.py create mode 100644 tests/units/test_state_minification.py create mode 100644 uploaded_files/test.txt diff --git a/reflex/environment.py b/reflex/environment.py index 279fc5f60c1..481caa4ce4d 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -486,6 +486,15 @@ class PerformanceMode(enum.Enum): OFF = "off" +@enum.unique +class StateMinifyMode(enum.Enum): + """Mode for state name minification.""" + + DISABLED = "disabled" # Never minify state names (default) + ENABLED = "enabled" # Minify states that have explicit state_id + ENFORCE = "enforce" # Require all non-mixin states to have state_id + + class ExecutorType(enum.Enum): """Executor for compiling the frontend.""" @@ -688,6 +697,9 @@ class EnvironmentVariables: # The maximum size of the reflex state in kilobytes. REFLEX_STATE_SIZE_LIMIT: EnvVar[int] = env_var(1000) + # State name minification mode: disabled, enabled, or enforce. + REFLEX_MINIFY_STATES: EnvVar[StateMinifyMode] = env_var(StateMinifyMode.DISABLED) + # Whether to use the turbopack bundler. REFLEX_USE_TURBOPACK: EnvVar[bool] = env_var(False) diff --git a/reflex/state.py b/reflex/state.py index 572e67f67c0..888e19b75c3 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -102,6 +102,34 @@ # For BaseState.get_var_value VAR_TYPE = TypeVar("VAR_TYPE") +# Global registry: state_id -> state class (for duplicate detection) +_state_id_registry: dict[int, type[BaseState]] = {} + + +def _int_to_minified_name(state_id: int) -> str: + """Convert integer state_id to minified name using base-54 encoding. + + Args: + state_id: The integer state ID to convert. + + Returns: + The minified state name (e.g., 0->'a', 1->'b', 54->'ba'). + """ + # All possible chars for minified state name (valid JS identifiers) + chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" + base = len(chars) + + if state_id == 0: + return chars[0] + + name = "" + num = state_id + while num > 0: + name = chars[num % base] + name + num //= base + + return name + def _no_chain_background_task(state: BaseState, name: str, fn: Callable) -> Callable: """Protect against directly chaining a background task from another event handler. @@ -391,6 +419,9 @@ class BaseState(EvenMoreBasicBaseState): # Set of states which might need to be recomputed if vars in this state change. _potentially_dirty_states: ClassVar[set[str]] = set() + # The explicit state ID for minification (None = use full name). + _state_id: ClassVar[int | None] = None + # The parent state. parent_state: BaseState | None = field(default=None, is_var=False) @@ -508,20 +539,42 @@ def _validate_module_name(cls) -> None: raise NameError(msg) @classmethod - def __init_subclass__(cls, mixin: bool = False, **kwargs): + def __init_subclass__( + cls, mixin: bool = False, state_id: int | None = None, **kwargs + ): """Do some magic for the subclass initialization. Args: mixin: Whether the subclass is a mixin and should not be initialized. + state_id: Explicit state ID for minified state names. **kwargs: The kwargs to pass to the init_subclass method. Raises: - StateValueError: If a substate class shadows another. + StateValueError: If a substate class shadows another or duplicate state_id. """ from reflex.utils.exceptions import StateValueError super().__init_subclass__(**kwargs) + # Store state_id as class variable + cls._state_id = state_id + + # Validate state_id if provided (check for duplicates) + if state_id is not None: + if state_id in _state_id_registry: + existing_cls = _state_id_registry[state_id] + # Allow re-registration if it's the same class (e.g., module reload) + existing_key = f"{existing_cls.__module__}.{existing_cls.__name__}" + new_key = f"{cls.__module__}.{cls.__name__}" + if existing_key != new_key: + msg = ( + f"Duplicate state_id={state_id}. Already used by " + f"'{existing_cls.__module__}.{existing_cls.__name__}', " + f"cannot be reused by '{cls.__module__}.{cls.__name__}'." + ) + raise StateValueError(msg) + _state_id_registry[state_id] = cls + if cls._mixin: return @@ -988,9 +1041,34 @@ def get_name(cls) -> str: Returns: The name of the state. + + Raises: + StateValueError: If ENFORCE mode is set and state_id is missing. """ + from reflex.environment import StateMinifyMode + from reflex.utils.exceptions import StateValueError + module = cls.__module__.replace(".", "___") - return format.to_snake_case(f"{module}___{cls.__name__}") + full_name = format.to_snake_case(f"{module}___{cls.__name__}") + + minify_mode = environment.REFLEX_MINIFY_STATES.get() + + if minify_mode == StateMinifyMode.DISABLED: + return full_name + + if cls._state_id is not None: + return _int_to_minified_name(cls._state_id) + + # state_id not set + if minify_mode == StateMinifyMode.ENFORCE: + msg = ( + f"State '{cls.__module__}.{cls.__name__}' is missing required state_id. " + f"Add state_id parameter: class {cls.__name__}(rx.State, state_id=N)" + ) + raise StateValueError(msg) + + # ENABLED mode with no state_id - use full name + return full_name @classmethod @functools.lru_cache @@ -2464,7 +2542,7 @@ def is_serializable(value: Any) -> bool: T_STATE = TypeVar("T_STATE", bound=BaseState) -class State(BaseState): +class State(BaseState, state_id=0): """The app Base State.""" # The hydrated bool. diff --git a/reflex/vars/base.py b/reflex/vars/base.py index 8a8f0e754d1..549b4896d57 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -3540,6 +3540,7 @@ def __new__( bases: tuple[type], namespace: dict[str, Any], mixin: bool = False, + state_id: int | None = None, ) -> type: """Create a new class. @@ -3548,6 +3549,7 @@ def __new__( bases: The bases of the class. namespace: The namespace of the class. mixin: Whether the class is a mixin and should not be instantiated. + state_id: Explicit state ID for minified state names. Returns: The new class. @@ -3648,6 +3650,9 @@ def __new__( namespace["__inherited_fields__"] = inherited_fields namespace["__fields__"] = inherited_fields | own_fields namespace["_mixin"] = mixin + # Pass state_id to __init_subclass__ if provided (for BaseState subclasses) + if state_id is not None: + return super().__new__(cls, name, bases, namespace, state_id=state_id) return super().__new__(cls, name, bases, namespace) diff --git a/tests/integration/test_state_minification.py b/tests/integration/test_state_minification.py new file mode 100644 index 00000000000..8c0754cea4b --- /dev/null +++ b/tests/integration/test_state_minification.py @@ -0,0 +1,290 @@ +"""Integration tests for explicit state ID minification.""" + +from __future__ import annotations + +import os +from collections.abc import Generator +from functools import partial +from typing import TYPE_CHECKING + +import pytest +from selenium.webdriver.common.by import By + +from reflex.environment import StateMinifyMode, environment +from reflex.state import _int_to_minified_name, _state_id_registry +from reflex.testing import AppHarness + +if TYPE_CHECKING: + from selenium.webdriver.remote.webdriver import WebDriver + + +def StateMinificationApp(root_state_id: int, sub_state_id: int): + """Test app for state minification. + + Args: + root_state_id: The state_id for the root state. + sub_state_id: The state_id for the sub state. + """ + import reflex as rx + + class RootState(rx.State, state_id=root_state_id): + """Root state with explicit state_id.""" + + count: int = 0 + + @rx.event + def increment(self): + """Increment the count.""" + self.count += 1 + + class SubState(RootState, state_id=sub_state_id): + """Sub state with explicit state_id.""" + + message: str = "hello" + + @rx.event + def update_message(self): + """Update the message.""" + parent = self.parent_state + assert parent is not None + assert isinstance(parent, RootState) + self.message = f"count is {parent.count}" + + def index() -> rx.Component: + return rx.vstack( + rx.input( + value=RootState.router.session.client_token, + is_read_only=True, + id="token", + ), + rx.text(f"Root state name: {RootState.get_name()}", id="root_state_name"), + rx.text(f"Sub state name: {SubState.get_name()}", id="sub_state_name"), + rx.text("Count: ", id="count_label"), + rx.text(RootState.count, id="count_value"), + rx.text("Message: ", id="message_label"), + rx.text(SubState.message, id="message_value"), + rx.button("Increment", on_click=RootState.increment, id="increment_btn"), + rx.button( + "Update Message", on_click=SubState.update_message, id="update_msg_btn" + ), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(autouse=True) +def reset_state_registry(): + """Reset the state_id registry before and after each test.""" + _state_id_registry.clear() + yield + _state_id_registry.clear() + + +@pytest.fixture +def minify_disabled_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start app with REFLEX_MINIFY_STATES=disabled. + + Args: + app_harness_env: AppHarness or AppHarnessProd + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + Running AppHarness instance + """ + os.environ["REFLEX_MINIFY_STATES"] = "disabled" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + + with app_harness_env.create( + root=tmp_path_factory.mktemp("state_minify_disabled"), + app_name="state_minify_disabled", + app_source=partial(StateMinificationApp, root_state_id=0, sub_state_id=1), + ) as harness: + yield harness + + # Cleanup + os.environ.pop("REFLEX_MINIFY_STATES", None) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + + +@pytest.fixture +def minify_enabled_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start app with REFLEX_MINIFY_STATES=enabled. + + Args: + app_harness_env: AppHarness or AppHarnessProd + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + Running AppHarness instance + """ + os.environ["REFLEX_MINIFY_STATES"] = "enabled" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + + with app_harness_env.create( + root=tmp_path_factory.mktemp("state_minify_enabled"), + app_name="state_minify_enabled", + app_source=partial(StateMinificationApp, root_state_id=10, sub_state_id=11), + ) as harness: + yield harness + + # Cleanup + os.environ.pop("REFLEX_MINIFY_STATES", None) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + + +@pytest.fixture +def driver_disabled( + minify_disabled_app: AppHarness, +) -> Generator[WebDriver, None, None]: + """Get browser instance for disabled mode app. + + Args: + minify_disabled_app: harness for the app + + Yields: + WebDriver instance. + """ + assert minify_disabled_app.app_instance is not None, "app is not running" + driver = minify_disabled_app.frontend() + try: + yield driver + finally: + driver.quit() + + +@pytest.fixture +def driver_enabled( + minify_enabled_app: AppHarness, +) -> Generator[WebDriver, None, None]: + """Get browser instance for enabled mode app. + + Args: + minify_enabled_app: harness for the app + + Yields: + WebDriver instance. + """ + assert minify_enabled_app.app_instance is not None, "app is not running" + driver = minify_enabled_app.frontend() + try: + yield driver + finally: + driver.quit() + + +def test_state_minification_disabled( + minify_disabled_app: AppHarness, + driver_disabled: WebDriver, +) -> None: + """Test that DISABLED mode uses full state names. + + Args: + minify_disabled_app: harness for the app + driver_disabled: WebDriver instance + """ + assert minify_disabled_app.app_instance is not None + + # Wait for the app to load + token_input = AppHarness.poll_for_or_raise_timeout( + lambda: driver_disabled.find_element(By.ID, "token") + ) + assert token_input + token = minify_disabled_app.poll_for_value(token_input) + assert token + + # Check state names are full names (not minified) + root_state_name_el = driver_disabled.find_element(By.ID, "root_state_name") + sub_state_name_el = driver_disabled.find_element(By.ID, "sub_state_name") + + root_state_name = root_state_name_el.text + sub_state_name = sub_state_name_el.text + + # In disabled mode, names should be the full module___class_name format + assert "root_state" in root_state_name.lower() + assert "sub_state" in sub_state_name.lower() + # Full names should be long (not single char minified names) + # Extract just the state name part after "Root state name: " + root_name_only = ( + root_state_name.split(": ")[-1] if ": " in root_state_name else root_state_name + ) + sub_name_only = ( + sub_state_name.split(": ")[-1] if ": " in sub_state_name else sub_state_name + ) + assert len(root_name_only) > 5, f"Expected long name, got: {root_name_only}" + assert len(sub_name_only) > 5, f"Expected long name, got: {sub_name_only}" + + # Test that state updates work + count_value = driver_disabled.find_element(By.ID, "count_value") + assert count_value.text == "0" + + increment_btn = driver_disabled.find_element(By.ID, "increment_btn") + increment_btn.click() + + # Wait for count to update + AppHarness._poll_for(lambda: count_value.text == "1") + assert count_value.text == "1" + + +def test_state_minification_enabled( + minify_enabled_app: AppHarness, + driver_enabled: WebDriver, +) -> None: + """Test that ENABLED mode uses minified state names. + + Args: + minify_enabled_app: harness for the app + driver_enabled: WebDriver instance + """ + assert minify_enabled_app.app_instance is not None + + # Wait for the app to load + token_input = AppHarness.poll_for_or_raise_timeout( + lambda: driver_enabled.find_element(By.ID, "token") + ) + assert token_input + token = minify_enabled_app.poll_for_value(token_input) + assert token + + # Check state names are minified + root_state_name_el = driver_enabled.find_element(By.ID, "root_state_name") + sub_state_name_el = driver_enabled.find_element(By.ID, "sub_state_name") + + root_state_name = root_state_name_el.text + sub_state_name = sub_state_name_el.text + + # In enabled mode with state_id, names should be minified + # state_id=10 -> 'k', state_id=11 -> 'l' + expected_root_minified = _int_to_minified_name(10) + expected_sub_minified = _int_to_minified_name(11) + + assert expected_root_minified in root_state_name + assert expected_sub_minified in sub_state_name + + # Test that state updates work with minified names + count_value = driver_enabled.find_element(By.ID, "count_value") + assert count_value.text == "0" + + increment_btn = driver_enabled.find_element(By.ID, "increment_btn") + increment_btn.click() + + # Wait for count to update + AppHarness._poll_for(lambda: count_value.text == "1") + assert count_value.text == "1" + + # Test substate event handler works + message_value = driver_enabled.find_element(By.ID, "message_value") + assert message_value.text == "hello" + + update_msg_btn = driver_enabled.find_element(By.ID, "update_msg_btn") + update_msg_btn.click() + + # Wait for message to update + AppHarness._poll_for(lambda: "count is 1" in message_value.text) + assert message_value.text == "count is 1" diff --git a/tests/units/test_state_minification.py b/tests/units/test_state_minification.py new file mode 100644 index 00000000000..5cc446e9d1b --- /dev/null +++ b/tests/units/test_state_minification.py @@ -0,0 +1,176 @@ +"""Unit tests for state name minification.""" + +from __future__ import annotations + +import pytest + +from reflex.environment import StateMinifyMode, environment +from reflex.state import BaseState, _int_to_minified_name, _state_id_registry +from reflex.utils.exceptions import StateValueError + + +@pytest.fixture(autouse=True) +def reset_state_registry(): + """Reset the state_id registry before and after each test.""" + _state_id_registry.clear() + yield + _state_id_registry.clear() + + +@pytest.fixture +def reset_minify_mode(): + """Reset REFLEX_MINIFY_STATES to DISABLED after each test.""" + original = environment.REFLEX_MINIFY_STATES.get() + yield + environment.REFLEX_MINIFY_STATES.set(original) + + +class TestIntToMinifiedName: + """Tests for _int_to_minified_name function.""" + + def test_zero(self): + """Test that 0 maps to 'a'.""" + assert _int_to_minified_name(0) == "a" + + def test_single_char(self): + """Test single character mappings.""" + assert _int_to_minified_name(1) == "b" + assert _int_to_minified_name(25) == "z" + assert _int_to_minified_name(26) == "A" + assert _int_to_minified_name(51) == "Z" + assert _int_to_minified_name(52) == "$" + assert _int_to_minified_name(53) == "_" + + def test_two_chars(self): + """Test two character mappings (base 54).""" + # 54 = 1*54 + 0 -> 'ba' + assert _int_to_minified_name(54) == "ba" + # 55 = 1*54 + 1 -> 'bb' + assert _int_to_minified_name(55) == "bb" + + def test_unique_names(self): + """Test that a large range of IDs produce unique names.""" + names = set() + for i in range(10000): + name = _int_to_minified_name(i) + assert name not in names, f"Duplicate name {name} for id {i}" + names.add(name) + + +class TestStateIdValidation: + """Tests for state_id validation in __init_subclass__.""" + + def test_state_with_explicit_id(self): + """Test that a state can be created with an explicit state_id.""" + + class TestState(BaseState, state_id=100): + pass + + assert TestState._state_id == 100 + assert 100 in _state_id_registry + assert _state_id_registry[100] is TestState + + def test_state_without_id(self): + """Test that a state can be created without state_id.""" + + class TestState(BaseState): + pass + + assert TestState._state_id is None + + def test_duplicate_state_id_raises(self): + """Test that duplicate state_id raises StateValueError.""" + + class FirstState(BaseState, state_id=200): + pass + + with pytest.raises(StateValueError, match="Duplicate state_id=200"): + + class SecondState(BaseState, state_id=200): + pass + + +class TestGetNameMinification: + """Tests for get_name with minification modes.""" + + def test_disabled_mode_uses_full_name(self, reset_minify_mode): + """Test DISABLED mode always uses full name even with state_id.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + + class TestState(BaseState, state_id=300): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + # Should be full name, not minified + assert "test_state" in name.lower() + assert name != _int_to_minified_name(300) + + def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): + """Test ENABLED mode with state_id uses minified name.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + + class TestState(BaseState, state_id=301): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + assert name == _int_to_minified_name(301) + + def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): + """Test ENABLED mode without state_id uses full name.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + + class TestState(BaseState): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + # Should contain the class name + assert "test_state" in name.lower() + + def test_enforce_mode_without_id_raises(self, reset_minify_mode): + """Test ENFORCE mode without state_id raises error during class definition.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) + + # Error is raised during class definition because get_name() is called + # during __init_subclass__ + with pytest.raises(StateValueError, match="missing required state_id"): + + class TestState(BaseState): + pass + + def test_enforce_mode_with_id_uses_minified(self, reset_minify_mode): + """Test ENFORCE mode with state_id uses minified name.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) + + class TestState(BaseState, state_id=302): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + assert name == _int_to_minified_name(302) + + +class TestMixinState: + """Tests for mixin states.""" + + def test_mixin_no_state_id_required(self, reset_minify_mode): + """Test that mixin states don't require state_id even in ENFORCE mode.""" + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) + + class MixinState(BaseState, mixin=True): + pass + + # Mixin states should not raise even without state_id + assert MixinState._state_id is None + # Mixin states have _mixin = True set, so get_name isn't typically called + # but the class should be created without error diff --git a/uploaded_files/test.txt b/uploaded_files/test.txt new file mode 100644 index 00000000000..fdffb5316f1 --- /dev/null +++ b/uploaded_files/test.txt @@ -0,0 +1 @@ +test file contents! \ No newline at end of file From 1c5114d5eee0a0ff336b03834298da776e1f7274 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 24 Jan 2026 20:54:10 +0100 Subject: [PATCH 02/28] drop uploaded_files, don't allow state_id for mixins --- reflex/state.py | 15 +++++++++++---- tests/units/test_state_minification.py | 7 +++++++ uploaded_files/test.txt | 1 - 3 files changed, 18 insertions(+), 5 deletions(-) delete mode 100644 uploaded_files/test.txt diff --git a/reflex/state.py b/reflex/state.py index 888e19b75c3..6e745cdd26d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -556,7 +556,17 @@ def __init_subclass__( super().__init_subclass__(**kwargs) - # Store state_id as class variable + # Mixin states cannot have state_id + if cls._mixin: + if state_id is not None: + msg = ( + f"Mixin state '{cls.__module__}.{cls.__name__}' cannot have a state_id. " + "Remove state_id or mixin=True." + ) + raise StateValueError(msg) + return + + # Store state_id as class variable (only for non-mixins) cls._state_id = state_id # Validate state_id if provided (check for duplicates) @@ -575,9 +585,6 @@ def __init_subclass__( raise StateValueError(msg) _state_id_registry[state_id] = cls - if cls._mixin: - return - # Handle locally-defined states for pickling. if "" in cls.__qualname__: cls._handle_local_def() diff --git a/tests/units/test_state_minification.py b/tests/units/test_state_minification.py index 5cc446e9d1b..ac2148efa60 100644 --- a/tests/units/test_state_minification.py +++ b/tests/units/test_state_minification.py @@ -174,3 +174,10 @@ class MixinState(BaseState, mixin=True): assert MixinState._state_id is None # Mixin states have _mixin = True set, so get_name isn't typically called # but the class should be created without error + + def test_mixin_with_state_id_raises(self): + """Test that mixin states cannot have state_id.""" + with pytest.raises(StateValueError, match="cannot have a state_id"): + + class MixinWithId(BaseState, mixin=True, state_id=999): + pass diff --git a/uploaded_files/test.txt b/uploaded_files/test.txt deleted file mode 100644 index fdffb5316f1..00000000000 --- a/uploaded_files/test.txt +++ /dev/null @@ -1 +0,0 @@ -test file contents! \ No newline at end of file From 62bf127ad09b03451ed19adaca8c985eefdb80a2 Mon Sep 17 00:00:00 2001 From: benedikt-bartscher <31854409+benedikt-bartscher@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:09:03 +0100 Subject: [PATCH 03/28] Update reflex/state.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- reflex/state.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/reflex/state.py b/reflex/state.py index 6e745cdd26d..6f21c8ac949 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -114,7 +114,13 @@ def _int_to_minified_name(state_id: int) -> str: Returns: The minified state name (e.g., 0->'a', 1->'b', 54->'ba'). + + Raises: + ValueError: If state_id is negative. """ + if state_id < 0: + raise ValueError(f"state_id must be non-negative, got {state_id}") + # All possible chars for minified state name (valid JS identifiers) chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" base = len(chars) From 0858c9e2eac54619b94f257b43f70af3310c3a60 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 24 Jan 2026 21:23:24 +0100 Subject: [PATCH 04/28] ruffing --- reflex/state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 6f21c8ac949..1795b98b719 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -119,8 +119,9 @@ def _int_to_minified_name(state_id: int) -> str: ValueError: If state_id is negative. """ if state_id < 0: - raise ValueError(f"state_id must be non-negative, got {state_id}") - + msg = f"state_id must be non-negative, got {state_id}" + raise ValueError(msg) + # All possible chars for minified state name (valid JS identifiers) chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" base = len(chars) From be13be5a2391a26e71fe40918ac7dd20c169c2ee Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 24 Jan 2026 22:43:27 +0100 Subject: [PATCH 05/28] minify event names as well --- .gitignore | 3 +- reflex/environment.py | 15 +- reflex/event.py | 9 + reflex/state.py | 75 +++- reflex/utils/format.py | 15 +- ...e_minification.py => test_minification.py} | 145 +++++- tests/units/test_minification.py | 411 ++++++++++++++++++ tests/units/test_state_minification.py | 183 -------- 8 files changed, 637 insertions(+), 219 deletions(-) rename tests/integration/{test_state_minification.py => test_minification.py} (60%) create mode 100644 tests/units/test_minification.py delete mode 100644 tests/units/test_state_minification.py diff --git a/.gitignore b/.gitignore index 508d57ca9d6..c4c0a159276 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ reflex.db node_modules package-lock.json *.pyi -.pre-commit-config.yaml \ No newline at end of file +.pre-commit-config.yaml +uploaded_files/* diff --git a/reflex/environment.py b/reflex/environment.py index 481caa4ce4d..87fcf4484d7 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -487,12 +487,12 @@ class PerformanceMode(enum.Enum): @enum.unique -class StateMinifyMode(enum.Enum): - """Mode for state name minification.""" +class MinifyMode(enum.Enum): + """Mode for state/event name minification.""" - DISABLED = "disabled" # Never minify state names (default) - ENABLED = "enabled" # Minify states that have explicit state_id - ENFORCE = "enforce" # Require all non-mixin states to have state_id + DISABLED = "disabled" # Never minify names (default) + ENABLED = "enabled" # Minify items that have explicit IDs + ENFORCE = "enforce" # Require all items to have explicit IDs class ExecutorType(enum.Enum): @@ -698,7 +698,10 @@ class EnvironmentVariables: REFLEX_STATE_SIZE_LIMIT: EnvVar[int] = env_var(1000) # State name minification mode: disabled, enabled, or enforce. - REFLEX_MINIFY_STATES: EnvVar[StateMinifyMode] = env_var(StateMinifyMode.DISABLED) + REFLEX_MINIFY_STATES: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + + # Event handler name minification mode: disabled, enabled, or enforce. + REFLEX_MINIFY_EVENTS: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) # Whether to use the turbopack bundler. REFLEX_USE_TURBOPACK: EnvVar[bool] = env_var(False) diff --git a/reflex/event.py b/reflex/event.py index 748fcd27802..e0c7367bfdd 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -89,6 +89,7 @@ def substate_token(self) -> str: _EVENT_FIELDS: set[str] = {f.name for f in dataclasses.fields(Event)} BACKGROUND_TASK_MARKER = "_reflex_background_task" +EVENT_ID_MARKER = "_rx_event_id" @dataclasses.dataclass( @@ -2311,6 +2312,7 @@ class EventNamespace: # Constants BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER + EVENT_ID_MARKER = EVENT_ID_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA upload_files = upload_files @@ -2334,6 +2336,7 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, + event_id: int | None = None, ) -> Callable[ [Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]] # pyright: ignore [reportInvalidTypeVarUse] ]: ... @@ -2349,6 +2352,7 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, + event_id: int | None = None, ) -> EventCallback[Unpack[P]]: ... def __new__( @@ -2361,6 +2365,7 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, + event_id: int | None = None, ) -> ( EventCallback[Unpack[P]] | Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]] @@ -2375,6 +2380,7 @@ def __new__( throttle: Throttle the event handler to limit calls (in milliseconds). debounce: Debounce the event handler to delay calls (in milliseconds). temporal: Whether the event should be dropped when the backend is down. + event_id: Optional integer ID for deterministic minified event names. Raises: TypeError: If background is True and the function is not a coroutine or async generator. # noqa: DAR402 @@ -2462,6 +2468,9 @@ def wrapper( event_actions = _build_event_actions() if event_actions: func._rx_event_actions = event_actions # pyright: ignore [reportFunctionMemberAccess] + # Store event_id on the function for minification + if event_id is not None: + setattr(func, EVENT_ID_MARKER, event_id) return func # pyright: ignore [reportReturnType] if func is not None: diff --git a/reflex/state.py b/reflex/state.py index 1795b98b719..e7109fca4ce 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -42,6 +42,7 @@ from reflex.environment import PerformanceMode, environment from reflex.event import ( BACKGROUND_TASK_MARKER, + EVENT_ID_MARKER, Event, EventHandler, EventSpec, @@ -429,6 +430,9 @@ class BaseState(EvenMoreBasicBaseState): # The explicit state ID for minification (None = use full name). _state_id: ClassVar[int | None] = None + # Per-class registry mapping event_id -> event handler name for minification. + _event_id_to_name: ClassVar[builtins.dict[int, str]] = {} + # The parent state. parent_state: BaseState | None = field(default=None, is_var=False) @@ -711,6 +715,36 @@ def __init_subclass__( cls.event_handlers[name] = handler setattr(cls, name, handler) + # Build event_id registry and validate uniqueness within this state class + cls._event_id_to_name = {} + missing_event_ids: list[str] = [] + for name, fn in events.items(): + event_id = getattr(fn, EVENT_ID_MARKER, None) + if event_id is not None: + if event_id in cls._event_id_to_name: + existing_name = cls._event_id_to_name[event_id] + msg = ( + f"Duplicate event_id={event_id} in state '{cls.__name__}': " + f"handlers '{existing_name}' and '{name}' cannot share the same event_id." + ) + raise StateValueError(msg) + cls._event_id_to_name[event_id] = name + else: + missing_event_ids.append(name) + + # In ENFORCE mode, all event handlers must have event_id + from reflex.environment import MinifyMode + + if ( + environment.REFLEX_MINIFY_EVENTS.get() == MinifyMode.ENFORCE + and missing_event_ids + ): + msg = ( + f"State '{cls.__name__}' in ENFORCE mode: event handlers " + f"{missing_event_ids} are missing required event_id." + ) + raise StateValueError(msg) + # Initialize per-class var dependency tracking. cls._var_dependencies = {} cls._init_var_dependency_dicts() @@ -753,6 +787,10 @@ def _copy_fn(fn: Callable) -> Callable: newfn.__annotations__ = fn.__annotations__ if mark := getattr(fn, BACKGROUND_TASK_MARKER, None): setattr(newfn, BACKGROUND_TASK_MARKER, mark) + # Preserve event_id for minification + event_id = getattr(fn, EVENT_ID_MARKER, None) + if event_id is not None: + object.__setattr__(newfn, EVENT_ID_MARKER, event_id) return newfn @staticmethod @@ -1059,7 +1097,7 @@ def get_name(cls) -> str: Raises: StateValueError: If ENFORCE mode is set and state_id is missing. """ - from reflex.environment import StateMinifyMode + from reflex.environment import MinifyMode from reflex.utils.exceptions import StateValueError module = cls.__module__.replace(".", "___") @@ -1067,14 +1105,14 @@ def get_name(cls) -> str: minify_mode = environment.REFLEX_MINIFY_STATES.get() - if minify_mode == StateMinifyMode.DISABLED: + if minify_mode == MinifyMode.DISABLED: return full_name if cls._state_id is not None: return _int_to_minified_name(cls._state_id) # state_id not set - if minify_mode == StateMinifyMode.ENFORCE: + if minify_mode == MinifyMode.ENFORCE: msg = ( f"State '{cls.__module__}.{cls.__name__}' is missing required state_id. " f"Add state_id parameter: class {cls.__name__}(rx.State, state_id=N)" @@ -1797,6 +1835,25 @@ async def get_var_value(self, var: Var[VAR_TYPE]) -> VAR_TYPE: ) return getattr(other_state, var_data.field_name) + @classmethod + def _get_original_event_name(cls, minified_name: str) -> str | None: + """Look up the original event handler name from a minified name. + + This is used when the frontend sends back minified event names + and the backend needs to find the actual event handler. + + Args: + minified_name: The minified event name (e.g., 'a'). + + Returns: + The original event handler name, or None if not found. + """ + # Build reverse lookup: minified_name -> original_name + for event_id, original_name in cls._event_id_to_name.items(): + if _int_to_minified_name(event_id) == minified_name: + return original_name + return None + def _get_event_handler( self, event: Event ) -> tuple[BaseState | StateProxy, EventHandler]: @@ -1819,7 +1876,17 @@ def _get_event_handler( if not substate: msg = "The value of state cannot be None when processing an event." raise ValueError(msg) - handler = substate.event_handlers[name] + + # Try to look up the handler directly first + handler = substate.event_handlers.get(name) + if handler is None: + # If not found, the name might be minified - try reverse lookup + original_name = substate._get_original_event_name(name) + if original_name is not None: + handler = substate.event_handlers.get(original_name) + if handler is None: + msg = f"Event handler '{name}' not found in state '{type(substate).__name__}'" + raise KeyError(msg) # For background tasks, proxy the state if handler.is_background: diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 3093e455d55..6e094049103 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -446,8 +446,12 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: handler: The event handler to get the parts of. Returns: - The state and function name. + The state and function name (possibly minified based on REFLEX_MINIFY_EVENTS). """ + from reflex.environment import MinifyMode, environment + from reflex.event import EVENT_ID_MARKER + from reflex.state import State, _int_to_minified_name + # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") @@ -461,11 +465,16 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: # Get the function name name = parts[-1] - from reflex.state import State - if state_full_name == FRONTEND_EVENT_STATE and name not in State.__dict__: return ("", to_snake_case(handler.fn.__qualname__)) + # Check for event_id minification + mode = environment.REFLEX_MINIFY_EVENTS.get() + if mode != MinifyMode.DISABLED: + event_id = getattr(handler.fn, EVENT_ID_MARKER, None) + if event_id is not None: + name = _int_to_minified_name(event_id) + return (state_full_name, name) diff --git a/tests/integration/test_state_minification.py b/tests/integration/test_minification.py similarity index 60% rename from tests/integration/test_state_minification.py rename to tests/integration/test_minification.py index 8c0754cea4b..b60c7f16260 100644 --- a/tests/integration/test_state_minification.py +++ b/tests/integration/test_minification.py @@ -1,4 +1,4 @@ -"""Integration tests for explicit state ID minification.""" +"""Integration tests for state and event handler minification.""" from __future__ import annotations @@ -10,7 +10,7 @@ import pytest from selenium.webdriver.common.by import By -from reflex.environment import StateMinifyMode, environment +from reflex.environment import MinifyMode, environment from reflex.state import _int_to_minified_name, _state_id_registry from reflex.testing import AppHarness @@ -18,21 +18,29 @@ from selenium.webdriver.remote.webdriver import WebDriver -def StateMinificationApp(root_state_id: int, sub_state_id: int): - """Test app for state minification. +def MinificationApp( + root_state_id: int, + sub_state_id: int, + increment_event_id: int | None = None, + update_message_event_id: int | None = None, +): + """Test app for state and event handler minification. Args: root_state_id: The state_id for the root state. sub_state_id: The state_id for the sub state. + increment_event_id: The event_id for the increment event handler. + update_message_event_id: The event_id for the update_message event handler. """ import reflex as rx + from reflex.utils import format class RootState(rx.State, state_id=root_state_id): """Root state with explicit state_id.""" count: int = 0 - @rx.event + @rx.event(event_id=increment_event_id) def increment(self): """Increment the count.""" self.count += 1 @@ -42,7 +50,7 @@ class SubState(RootState, state_id=sub_state_id): message: str = "hello" - @rx.event + @rx.event(event_id=update_message_event_id) def update_message(self): """Update the message.""" parent = self.parent_state @@ -50,6 +58,15 @@ def update_message(self): assert isinstance(parent, RootState) self.message = f"count is {parent.count}" + # Get formatted event handler names for display + # Use event_handlers dict to get the actual EventHandler objects + increment_handler_name = format.format_event_handler( + RootState.event_handlers["increment"] + ) + update_handler_name = format.format_event_handler( + SubState.event_handlers["update_message"] + ) + def index() -> rx.Component: return rx.vstack( rx.input( @@ -59,6 +76,14 @@ def index() -> rx.Component: ), rx.text(f"Root state name: {RootState.get_name()}", id="root_state_name"), rx.text(f"Sub state name: {SubState.get_name()}", id="sub_state_name"), + rx.text( + f"Increment handler: {increment_handler_name}", + id="increment_handler_name", + ), + rx.text( + f"Update handler: {update_handler_name}", + id="update_handler_name", + ), rx.text("Count: ", id="count_label"), rx.text(RootState.count, id="count_value"), rx.text("Message: ", id="message_label"), @@ -96,18 +121,28 @@ def minify_disabled_app( Running AppHarness instance """ os.environ["REFLEX_MINIFY_STATES"] = "disabled" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + os.environ["REFLEX_MINIFY_EVENTS"] = "disabled" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) with app_harness_env.create( - root=tmp_path_factory.mktemp("state_minify_disabled"), - app_name="state_minify_disabled", - app_source=partial(StateMinificationApp, root_state_id=0, sub_state_id=1), + root=tmp_path_factory.mktemp("minify_disabled"), + app_name="minify_disabled", + app_source=partial( + MinificationApp, + root_state_id=0, + sub_state_id=1, + increment_event_id=0, + update_message_event_id=0, + ), ) as harness: yield harness # Cleanup os.environ.pop("REFLEX_MINIFY_STATES", None) - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + os.environ.pop("REFLEX_MINIFY_EVENTS", None) + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) @pytest.fixture @@ -115,7 +150,7 @@ def minify_enabled_app( app_harness_env: type[AppHarness], tmp_path_factory: pytest.TempPathFactory, ) -> Generator[AppHarness, None, None]: - """Start app with REFLEX_MINIFY_STATES=enabled. + """Start app with minification enabled. Args: app_harness_env: AppHarness or AppHarnessProd @@ -125,18 +160,28 @@ def minify_enabled_app( Running AppHarness instance """ os.environ["REFLEX_MINIFY_STATES"] = "enabled" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + os.environ["REFLEX_MINIFY_EVENTS"] = "enabled" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) with app_harness_env.create( - root=tmp_path_factory.mktemp("state_minify_enabled"), - app_name="state_minify_enabled", - app_source=partial(StateMinificationApp, root_state_id=10, sub_state_id=11), + root=tmp_path_factory.mktemp("minify_enabled"), + app_name="minify_enabled", + app_source=partial( + MinificationApp, + root_state_id=10, + sub_state_id=11, + increment_event_id=0, + update_message_event_id=0, + ), ) as harness: yield harness # Cleanup os.environ.pop("REFLEX_MINIFY_STATES", None) - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + os.environ.pop("REFLEX_MINIFY_EVENTS", None) + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) @pytest.fixture @@ -179,11 +224,11 @@ def driver_enabled( driver.quit() -def test_state_minification_disabled( +def test_minification_disabled( minify_disabled_app: AppHarness, driver_disabled: WebDriver, ) -> None: - """Test that DISABLED mode uses full state names. + """Test that DISABLED mode uses full state and event names. Args: minify_disabled_app: harness for the app @@ -220,6 +265,20 @@ def test_state_minification_disabled( assert len(root_name_only) > 5, f"Expected long name, got: {root_name_only}" assert len(sub_name_only) > 5, f"Expected long name, got: {sub_name_only}" + # Check event handler names are full names (not minified) + increment_handler_el = driver_disabled.find_element(By.ID, "increment_handler_name") + update_handler_el = driver_disabled.find_element(By.ID, "update_handler_name") + + increment_handler = increment_handler_el.text + update_handler = update_handler_el.text + + # In disabled mode, event handler names should contain the full method names + assert "increment" in increment_handler.lower() + assert "update_message" in update_handler.lower() + # The format should be "state_name.method_name", so check for the dot + assert "." in increment_handler + assert "." in update_handler + # Test that state updates work count_value = driver_disabled.find_element(By.ID, "count_value") assert count_value.text == "0" @@ -232,11 +291,11 @@ def test_state_minification_disabled( assert count_value.text == "1" -def test_state_minification_enabled( +def test_minification_enabled( minify_enabled_app: AppHarness, driver_enabled: WebDriver, ) -> None: - """Test that ENABLED mode uses minified state names. + """Test that ENABLED mode uses minified state and event names. Args: minify_enabled_app: harness for the app @@ -267,6 +326,48 @@ def test_state_minification_enabled( assert expected_root_minified in root_state_name assert expected_sub_minified in sub_state_name + # Check event handler names are minified + increment_handler_el = driver_enabled.find_element(By.ID, "increment_handler_name") + update_handler_el = driver_enabled.find_element(By.ID, "update_handler_name") + + increment_handler_text = increment_handler_el.text + update_handler_text = update_handler_el.text + + # Extract just the handler name part after "Increment handler: " + increment_handler = ( + increment_handler_text.split(": ")[-1] + if ": " in increment_handler_text + else increment_handler_text + ) + update_handler = ( + update_handler_text.split(": ")[-1] + if ": " in update_handler_text + else update_handler_text + ) + + # In enabled mode with event_id, names should be minified + # event_id=0 -> 'a' for both handlers + expected_event_minified = _int_to_minified_name(0) + + # Event handler format: "state_name.event_name" + # For increment: "k.a" (state_id=10 -> 'k', event_id=0 -> 'a') + # For update_message: "k.l.a" (state_id=10.11 -> 'k.l', event_id=0 -> 'a') + # The event name should be minified to 'a' + assert increment_handler.endswith(f".{expected_event_minified}"), ( + f"Expected minified event name, got: {increment_handler}" + ) + assert update_handler.endswith(f".{expected_event_minified}"), ( + f"Expected minified event name, got: {update_handler}" + ) + + # The handler names should NOT contain the original method names + assert "increment" not in increment_handler.lower(), ( + f"Expected minified name without 'increment', got: {increment_handler}" + ) + assert "update_message" not in update_handler.lower(), ( + f"Expected minified name without 'update_message', got: {update_handler}" + ) + # Test that state updates work with minified names count_value = driver_enabled.find_element(By.ID, "count_value") assert count_value.text == "0" @@ -278,7 +379,7 @@ def test_state_minification_enabled( AppHarness._poll_for(lambda: count_value.text == "1") assert count_value.text == "1" - # Test substate event handler works + # Test substate event handler works with minified names message_value = driver_enabled.find_element(By.ID, "message_value") assert message_value.text == "hello" diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py new file mode 100644 index 00000000000..3dcc72372ae --- /dev/null +++ b/tests/units/test_minification.py @@ -0,0 +1,411 @@ +"""Unit tests for state and event handler minification.""" + +from __future__ import annotations + +import pytest + +from reflex.environment import MinifyMode, environment +from reflex.event import EVENT_ID_MARKER +from reflex.state import BaseState, _int_to_minified_name, _state_id_registry +from reflex.utils.exceptions import StateValueError + + +@pytest.fixture(autouse=True) +def reset_state_registry(): + """Reset the state_id registry before and after each test.""" + _state_id_registry.clear() + yield + _state_id_registry.clear() + + +@pytest.fixture +def reset_minify_mode(): + """Reset minify modes to DISABLED after each test.""" + original_states = environment.REFLEX_MINIFY_STATES.get() + original_events = environment.REFLEX_MINIFY_EVENTS.get() + yield + environment.REFLEX_MINIFY_STATES.set(original_states) + environment.REFLEX_MINIFY_EVENTS.set(original_events) + + +class TestIntToMinifiedName: + """Tests for _int_to_minified_name function.""" + + def test_zero(self): + """Test that 0 maps to 'a'.""" + assert _int_to_minified_name(0) == "a" + + def test_single_char(self): + """Test single character mappings.""" + assert _int_to_minified_name(1) == "b" + assert _int_to_minified_name(25) == "z" + assert _int_to_minified_name(26) == "A" + assert _int_to_minified_name(51) == "Z" + assert _int_to_minified_name(52) == "$" + assert _int_to_minified_name(53) == "_" + + def test_two_chars(self): + """Test two character mappings (base 54).""" + # 54 = 1*54 + 0 -> 'ba' + assert _int_to_minified_name(54) == "ba" + # 55 = 1*54 + 1 -> 'bb' + assert _int_to_minified_name(55) == "bb" + + def test_unique_names(self): + """Test that a large range of IDs produce unique names.""" + names = set() + for i in range(10000): + name = _int_to_minified_name(i) + assert name not in names, f"Duplicate name {name} for id {i}" + names.add(name) + + +class TestStateIdValidation: + """Tests for state_id validation in __init_subclass__.""" + + def test_state_with_explicit_id(self): + """Test that a state can be created with an explicit state_id.""" + + class TestState(BaseState, state_id=100): + pass + + assert TestState._state_id == 100 + assert 100 in _state_id_registry + assert _state_id_registry[100] is TestState + + def test_state_without_id(self): + """Test that a state can be created without state_id.""" + + class TestState(BaseState): + pass + + assert TestState._state_id is None + + def test_duplicate_state_id_raises(self): + """Test that duplicate state_id raises StateValueError.""" + + class FirstState(BaseState, state_id=200): + pass + + with pytest.raises(StateValueError, match="Duplicate state_id=200"): + + class SecondState(BaseState, state_id=200): + pass + + +class TestGetNameMinification: + """Tests for get_name with minification modes.""" + + def test_disabled_mode_uses_full_name(self, reset_minify_mode): + """Test DISABLED mode always uses full name even with state_id.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + + class TestState(BaseState, state_id=300): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + # Should be full name, not minified + assert "test_state" in name.lower() + assert name != _int_to_minified_name(300) + + def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): + """Test ENABLED mode with state_id uses minified name.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + + class TestState(BaseState, state_id=301): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + assert name == _int_to_minified_name(301) + + def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): + """Test ENABLED mode without state_id uses full name.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + + class TestState(BaseState): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + # Should contain the class name + assert "test_state" in name.lower() + + def test_enforce_mode_without_id_raises(self, reset_minify_mode): + """Test ENFORCE mode without state_id raises error during class definition.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + + # Error is raised during class definition because get_name() is called + # during __init_subclass__ + with pytest.raises(StateValueError, match="missing required state_id"): + + class TestState(BaseState): + pass + + def test_enforce_mode_with_id_uses_minified(self, reset_minify_mode): + """Test ENFORCE mode with state_id uses minified name.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + + class TestState(BaseState, state_id=302): + pass + + # Clear the lru_cache to get fresh result + TestState.get_name.cache_clear() + + name = TestState.get_name() + assert name == _int_to_minified_name(302) + + +class TestMixinState: + """Tests for mixin states.""" + + def test_mixin_no_state_id_required(self, reset_minify_mode): + """Test that mixin states don't require state_id even in ENFORCE mode.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + + class MixinState(BaseState, mixin=True): + pass + + # Mixin states should not raise even without state_id + assert MixinState._state_id is None + # Mixin states have _mixin = True set, so get_name isn't typically called + # but the class should be created without error + + def test_mixin_with_state_id_raises(self): + """Test that mixin states cannot have state_id.""" + with pytest.raises(StateValueError, match="cannot have a state_id"): + + class MixinWithId(BaseState, mixin=True, state_id=999): + pass + + +class TestEventIdValidation: + """Tests for event_id validation in __init_subclass__.""" + + def test_event_with_explicit_id(self): + """Test that an event handler can be created with an explicit event_id.""" + import reflex as rx + + class TestState(BaseState, state_id=400): + @rx.event(event_id=0) + def my_handler(self): + pass + + assert 0 in TestState._event_id_to_name + assert TestState._event_id_to_name[0] == "my_handler" + + def test_event_without_id(self): + """Test that an event handler can be created without event_id.""" + import reflex as rx + + class TestState(BaseState, state_id=401): + @rx.event + def my_handler(self): + pass + + # Should not be in the registry + assert 0 not in TestState._event_id_to_name + + def test_duplicate_event_id_within_state_raises(self): + """Test that duplicate event_id within same state raises StateValueError.""" + import reflex as rx + + with pytest.raises(StateValueError, match="Duplicate event_id=0"): + + class TestState(BaseState, state_id=402): + @rx.event(event_id=0) + def handler1(self): + pass + + @rx.event(event_id=0) + def handler2(self): + pass + + def test_same_event_id_across_states_allowed(self): + """Test that same event_id can be used in different state classes.""" + import reflex as rx + + class StateA(BaseState, state_id=403): + @rx.event(event_id=0) + def handler(self): + pass + + class StateB(BaseState, state_id=404): + @rx.event(event_id=0) + def handler(self): + pass + + # Both should succeed - event_id is per-state + assert StateA._event_id_to_name[0] == "handler" + assert StateB._event_id_to_name[0] == "handler" + + def test_event_id_stored_on_function(self): + """Test that event_id is stored as EVENT_ID_MARKER on the function.""" + import reflex as rx + + @rx.event(event_id=42) + def standalone_handler(self): + pass + + assert hasattr(standalone_handler, EVENT_ID_MARKER) + assert getattr(standalone_handler, EVENT_ID_MARKER) == 42 + + +class TestEventHandlerMinification: + """Tests for event handler name minification in get_event_handler_parts.""" + + def test_disabled_mode_uses_full_name(self, reset_minify_mode): + """Test DISABLED mode uses full event name even with event_id.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) + + class TestState(BaseState, state_id=500): + @rx.event(event_id=0) + def my_handler(self): + pass + + handler = TestState.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # Should use full name, not minified + assert event_name == "my_handler" + + def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): + """Test ENABLED mode with event_id uses minified name.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + + class TestState(BaseState, state_id=501): + @rx.event(event_id=5) + def my_handler(self): + pass + + TestState.get_name.cache_clear() + handler = TestState.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # Should use minified name + assert event_name == _int_to_minified_name(5) + assert event_name == "f" + + def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): + """Test ENABLED mode without event_id uses full name.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + + class TestState(BaseState, state_id=502): + @rx.event + def my_handler(self): + pass + + TestState.get_name.cache_clear() + handler = TestState.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # Should use full name + assert event_name == "my_handler" + + def test_enforce_mode_without_event_id_raises(self, reset_minify_mode): + """Test ENFORCE mode without event_id raises error during class definition.""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENFORCE) + + with pytest.raises(StateValueError, match="missing required event_id"): + + class TestState(BaseState, state_id=503): + @rx.event + def my_handler(self): + pass + + def test_enforce_mode_with_event_id_works(self, reset_minify_mode): + """Test ENFORCE mode with event_id creates state successfully.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENFORCE) + + class TestState(BaseState, state_id=504): + @rx.event(event_id=0) + def my_handler(self): + pass + + TestState.get_name.cache_clear() + handler = TestState.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # Should use minified name + assert event_name == _int_to_minified_name(0) + assert event_name == "a" + + +class TestMixinEventHandlers: + """Tests for event handlers from mixin states.""" + + def test_mixin_event_id_preserved(self, reset_minify_mode): + """Test that event_id from mixin handlers is preserved when inherited.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + + class MixinState(BaseState, mixin=True): + @rx.event(event_id=10) + def mixin_handler(self): + pass + + # Need to inherit from both mixin AND a non-mixin base (BaseState) + # to create a non-mixin concrete state + class ConcreteState(MixinState, BaseState, state_id=600): + @rx.event(event_id=0) + def own_handler(self): + pass + + ConcreteState.get_name.cache_clear() + + # Both handlers should have their event_ids preserved + assert 10 in ConcreteState._event_id_to_name + assert ConcreteState._event_id_to_name[10] == "mixin_handler" + assert 0 in ConcreteState._event_id_to_name + assert ConcreteState._event_id_to_name[0] == "own_handler" + + # Check minified names + mixin_handler = ConcreteState.event_handlers["mixin_handler"] + own_handler = ConcreteState.event_handlers["own_handler"] + + _, mixin_name = get_event_handler_parts(mixin_handler) + _, own_name = get_event_handler_parts(own_handler) + + assert mixin_name == _int_to_minified_name(10) # "k" + assert own_name == _int_to_minified_name(0) # "a" + + def test_mixin_event_id_conflict_raises(self, reset_minify_mode): + """Test that conflicting event_ids from mixin and concrete state raises error.""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + + class MixinState(BaseState, mixin=True): + @rx.event(event_id=0) + def mixin_handler(self): + pass + + with pytest.raises(StateValueError, match="Duplicate event_id=0"): + # Need to inherit from both mixin AND a non-mixin base (BaseState) + class ConcreteState(MixinState, BaseState, state_id=601): + @rx.event(event_id=0) + def own_handler(self): + pass diff --git a/tests/units/test_state_minification.py b/tests/units/test_state_minification.py deleted file mode 100644 index ac2148efa60..00000000000 --- a/tests/units/test_state_minification.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Unit tests for state name minification.""" - -from __future__ import annotations - -import pytest - -from reflex.environment import StateMinifyMode, environment -from reflex.state import BaseState, _int_to_minified_name, _state_id_registry -from reflex.utils.exceptions import StateValueError - - -@pytest.fixture(autouse=True) -def reset_state_registry(): - """Reset the state_id registry before and after each test.""" - _state_id_registry.clear() - yield - _state_id_registry.clear() - - -@pytest.fixture -def reset_minify_mode(): - """Reset REFLEX_MINIFY_STATES to DISABLED after each test.""" - original = environment.REFLEX_MINIFY_STATES.get() - yield - environment.REFLEX_MINIFY_STATES.set(original) - - -class TestIntToMinifiedName: - """Tests for _int_to_minified_name function.""" - - def test_zero(self): - """Test that 0 maps to 'a'.""" - assert _int_to_minified_name(0) == "a" - - def test_single_char(self): - """Test single character mappings.""" - assert _int_to_minified_name(1) == "b" - assert _int_to_minified_name(25) == "z" - assert _int_to_minified_name(26) == "A" - assert _int_to_minified_name(51) == "Z" - assert _int_to_minified_name(52) == "$" - assert _int_to_minified_name(53) == "_" - - def test_two_chars(self): - """Test two character mappings (base 54).""" - # 54 = 1*54 + 0 -> 'ba' - assert _int_to_minified_name(54) == "ba" - # 55 = 1*54 + 1 -> 'bb' - assert _int_to_minified_name(55) == "bb" - - def test_unique_names(self): - """Test that a large range of IDs produce unique names.""" - names = set() - for i in range(10000): - name = _int_to_minified_name(i) - assert name not in names, f"Duplicate name {name} for id {i}" - names.add(name) - - -class TestStateIdValidation: - """Tests for state_id validation in __init_subclass__.""" - - def test_state_with_explicit_id(self): - """Test that a state can be created with an explicit state_id.""" - - class TestState(BaseState, state_id=100): - pass - - assert TestState._state_id == 100 - assert 100 in _state_id_registry - assert _state_id_registry[100] is TestState - - def test_state_without_id(self): - """Test that a state can be created without state_id.""" - - class TestState(BaseState): - pass - - assert TestState._state_id is None - - def test_duplicate_state_id_raises(self): - """Test that duplicate state_id raises StateValueError.""" - - class FirstState(BaseState, state_id=200): - pass - - with pytest.raises(StateValueError, match="Duplicate state_id=200"): - - class SecondState(BaseState, state_id=200): - pass - - -class TestGetNameMinification: - """Tests for get_name with minification modes.""" - - def test_disabled_mode_uses_full_name(self, reset_minify_mode): - """Test DISABLED mode always uses full name even with state_id.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - - class TestState(BaseState, state_id=300): - pass - - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - # Should be full name, not minified - assert "test_state" in name.lower() - assert name != _int_to_minified_name(300) - - def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): - """Test ENABLED mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) - - class TestState(BaseState, state_id=301): - pass - - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - assert name == _int_to_minified_name(301) - - def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): - """Test ENABLED mode without state_id uses full name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) - - class TestState(BaseState): - pass - - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - # Should contain the class name - assert "test_state" in name.lower() - - def test_enforce_mode_without_id_raises(self, reset_minify_mode): - """Test ENFORCE mode without state_id raises error during class definition.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - - # Error is raised during class definition because get_name() is called - # during __init_subclass__ - with pytest.raises(StateValueError, match="missing required state_id"): - - class TestState(BaseState): - pass - - def test_enforce_mode_with_id_uses_minified(self, reset_minify_mode): - """Test ENFORCE mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - - class TestState(BaseState, state_id=302): - pass - - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - assert name == _int_to_minified_name(302) - - -class TestMixinState: - """Tests for mixin states.""" - - def test_mixin_no_state_id_required(self, reset_minify_mode): - """Test that mixin states don't require state_id even in ENFORCE mode.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - - class MixinState(BaseState, mixin=True): - pass - - # Mixin states should not raise even without state_id - assert MixinState._state_id is None - # Mixin states have _mixin = True set, so get_name isn't typically called - # but the class should be created without error - - def test_mixin_with_state_id_raises(self): - """Test that mixin states cannot have state_id.""" - with pytest.raises(StateValueError, match="cannot have a state_id"): - - class MixinWithId(BaseState, mixin=True, state_id=999): - pass From e53c76af5bc1d5f94d6611ea74c8a71b92c58e3c Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 25 Jan 2026 00:53:35 +0100 Subject: [PATCH 06/28] wip fixing minifed state names + add cli --- reflex/.templates/web/utils/state.js | 17 +-- reflex/compiler/templates.py | 28 +++- reflex/constants/compiler.py | 12 -- reflex/reflex.py | 184 +++++++++++++++++++++++++++ reflex/state.py | 42 ++++-- tests/units/test_app.py | 2 +- tests/units/test_minification.py | 114 ++++++++++++++++- tests/units/test_state.py | 4 +- 8 files changed, 368 insertions(+), 35 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index e45857357ba..e46501929e5 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -17,6 +17,8 @@ import { onLoadInternalEvent, state_name, exception_state_name, + main_state_name, + update_vars_internal, } from "$/utils/context"; import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; @@ -56,10 +58,10 @@ export const generateUUID = () => { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { let r = Math.random() * 16; if (d > 0) { - r = ((d + r) % 16) | 0; + r = (d + r) % 16 | 0; d = Math.floor(d / 16); } else { - r = ((d2 + r) % 16) | 0; + r = (d2 + r) % 16 | 0; d2 = Math.floor(d2 / 16); } return (c == "x" ? r : (r & 0x7) | 0x8).toString(16); @@ -134,7 +136,7 @@ export const isStateful = () => { if (event_queue.length === 0) { return false; } - return event_queue.some((event) => event.name.startsWith("reflex___state")); + return event_queue.some((event) => event.name.startsWith(main_state_name)); }; /** @@ -1034,10 +1036,9 @@ export const useEventLoop = ( if (storage_to_state_map[e.key]) { const vars = {}; vars[storage_to_state_map[e.key]] = e.newValue; - const event = ReflexEvent( - `${state_name}.reflex___state____update_vars_internal_state.update_vars_internal`, - { vars: vars }, - ); + const event = ReflexEvent(`${state_name}.${update_vars_internal}`, { + vars: vars, + }); addEvents([event], e); } }; @@ -1072,7 +1073,7 @@ export const useEventLoop = ( } // Equivalent to routeChangeStart - runs when navigation begins - const main_state_dispatch = dispatch["reflex___state____state"]; + const main_state_dispatch = dispatch[main_state_name]; if (main_state_dispatch !== undefined) { main_state_dispatch({ is_hydrated_rx_state_: false }); } diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 2abcb6dd533..7eccca3e6ea 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -274,6 +274,20 @@ def context_template( Returns: Rendered context file content as string. """ + # Import state classes to get dynamic names (supports minification) + from reflex.state import ( + FrontendEventExceptionState, + OnLoadInternalState, + State, + UpdateVarsInternalState, + ) + + # Compute dynamic state names that respect minification settings + main_state_name = State.get_name() + on_load_internal = f"{OnLoadInternalState.get_name()}.on_load_internal" + update_vars_internal = f"{UpdateVarsInternalState.get_name()}.update_vars_internal" + exception_state_full = FrontendEventExceptionState.get_full_name() + initial_state = initial_state or {} state_contexts_str = "".join([ f"{format_state_name(state_name)}: createContext(null)," @@ -284,7 +298,11 @@ def context_template( rf""" export const state_name = "{state_name}" -export const exception_state_name = "{constants.CompileVars.FRONTEND_EXCEPTION_STATE_FULL}" +export const main_state_name = "{main_state_name}" + +export const update_vars_internal = "{update_vars_internal}" + +export const exception_state_name = "{exception_state_full}" // These events are triggered on initial load and each page navigation. export const onLoadInternalEvent = () => {{ @@ -296,7 +314,7 @@ def context_template( if (client_storage_vars && Object.keys(client_storage_vars).length !== 0) {{ internal_events.push( ReflexEvent( - '{state_name}.{constants.CompileVars.UPDATE_VARS_INTERNAL}', + '{state_name}.{update_vars_internal}', {{vars: client_storage_vars}}, ), ); @@ -304,7 +322,7 @@ def context_template( // `on_load_internal` triggers the correct on_load event(s) for the current page. // If the page does not define any on_load event, this will just set `is_hydrated = true`. - internal_events.push(ReflexEvent('{state_name}.{constants.CompileVars.ON_LOAD_INTERNAL}')); + internal_events.push(ReflexEvent('{state_name}.{on_load_internal}')); return internal_events; }} @@ -319,6 +337,10 @@ def context_template( else """ export const state_name = undefined +export const main_state_name = undefined + +export const update_vars_internal = undefined + export const exception_state_name = undefined export const onLoadInternalEvent = () => [] diff --git a/reflex/constants/compiler.py b/reflex/constants/compiler.py index 873cce69a14..4eefafef412 100644 --- a/reflex/constants/compiler.py +++ b/reflex/constants/compiler.py @@ -65,18 +65,6 @@ class CompileVars(SimpleNamespace): CONNECT_ERROR = "connectErrors" # The name of the function for converting a dict to an event. TO_EVENT = "ReflexEvent" - # The name of the internal on_load event. - ON_LOAD_INTERNAL = "reflex___state____on_load_internal_state.on_load_internal" - # The name of the internal event to update generic state vars. - UPDATE_VARS_INTERNAL = ( - "reflex___state____update_vars_internal_state.update_vars_internal" - ) - # The name of the frontend event exception state - FRONTEND_EXCEPTION_STATE = "reflex___state____frontend_event_exception_state" - # The full name of the frontend exception state - FRONTEND_EXCEPTION_STATE_FULL = ( - f"reflex___state____state.{FRONTEND_EXCEPTION_STATE}" - ) class PageNames(SimpleNamespace): diff --git a/reflex/reflex.py b/reflex/reflex.py index cb677c3173e..9057f844b20 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -2,6 +2,7 @@ from __future__ import annotations +import operator from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING @@ -842,6 +843,189 @@ def rename(new_name: str): rename_app(new_name, get_config().loglevel) +@cli.command(name="state-tree") +@loglevel_option +@click.option( + "--json", + "output_json", + is_flag=True, + help="Output as JSON.", +) +def state_tree(output_json: bool): + """Print the state tree with state_id's and event handlers with event_id's.""" + from reflex.event import EVENT_ID_MARKER + from reflex.state import BaseState, State, _int_to_minified_name + from reflex.utils import prerequisites + + # Load the user's app to register all state classes + prerequisites.get_app() + + def build_state_tree(state_cls: type[BaseState]) -> dict: + """Recursively build state tree data. + + Args: + state_cls: The state class to build the tree for. + + Returns: + A dictionary containing the state tree data. + """ + state_id = state_cls._state_id + + # Build event handlers list + handlers = [] + for name, handler in state_cls.event_handlers.items(): + event_id = getattr(handler.fn, EVENT_ID_MARKER, None) + handlers.append({ + "name": name, + "event_id": event_id, + "minified_name": ( + _int_to_minified_name(event_id) if event_id is not None else None + ), + }) + handlers.sort(key=operator.itemgetter("name")) + + # Build substates recursively + substates = [ + build_state_tree(substate) + for substate in sorted(state_cls.class_subclasses, key=lambda s: s.__name__) + ] + + return { + "name": state_cls.__name__, + "full_name": state_cls.get_full_name(), + "state_id": state_id, + "minified_name": ( + _int_to_minified_name(state_id) if state_id is not None else None + ), + "event_handlers": handlers, + "substates": substates, + } + + def print_state_tree(state_data: dict, prefix: str = "", is_last: bool = True): + """Print a state and its children as a tree. + + Args: + state_data: The state data dictionary. + prefix: The prefix for indentation. + is_last: Whether this is the last item in the current level. + """ + state_id = state_data["state_id"] + minified = state_data["minified_name"] + + if state_id is not None: + f'{state_data["name"]} (state_id={state_id} -> "{minified}")' + else: + f"{state_data['name']} (state_id=None)" + + # Calculate new prefix for children + child_prefix = prefix + (" " if is_last else "| ") + + # Print event handlers + handlers = state_data["event_handlers"] + substates = state_data["substates"] + has_substates = len(substates) > 0 + + if handlers: + handler_prefix = child_prefix + ("| " if has_substates else " ") + for i, handler in enumerate(handlers): + is_last_handler = i == len(handlers) - 1 + event_id = handler["event_id"] + if event_id is not None: + _ = ( + handler_prefix, + is_last_handler, + ) # silence unused variable warnings + + # Print substates recursively + for i, substate in enumerate(substates): + is_last_substate = i == len(substates) - 1 + print_state_tree(substate, child_prefix, is_last_substate) + + tree_data = build_state_tree(State) + + if output_json: + pass + else: + print_state_tree(tree_data) + + +@cli.command(name="state-lookup") +@loglevel_option +@click.option( + "--json", + "output_json", + is_flag=True, + help="Output detailed info as JSON.", +) +@click.argument("minified_path") +def state_lookup(output_json: bool, minified_path: str): + """Lookup a state by its minified path (e.g., 'a.bU').""" + from reflex.state import _minified_name_to_int, _state_id_registry + from reflex.utils import prerequisites + + # Load the user's app to register all state classes + prerequisites.get_app() + + # Parse the dotted path + parts = minified_path.split(".") + + # Resolve each part + result_parts = [] + for part in parts: + try: + state_id = _minified_name_to_int(part) + except ValueError as err: + raise SystemExit(1) from err + + state_cls = _state_id_registry.get(state_id) + if state_cls is None: + raise SystemExit(1) + + result_parts.append({ + "minified": part, + "state_id": state_id, + "module": state_cls.__module__, + "class": state_cls.__name__, + "full_name": state_cls.get_full_name(), + }) + + if output_json: + pass + else: + # Simple output: module.ClassName for each part + for _info in result_parts: + pass + + +@cli.command(name="state-next-id") +@loglevel_option +@click.option( + "--after-max", + is_flag=True, + help="Return max(state_id) + 1 instead of first gap.", +) +def state_next_id(after_max: bool): + """Print the next available state_id.""" + from reflex.state import _state_id_registry + from reflex.utils import prerequisites + + # Load the user's app to register all state classes + prerequisites.get_app() + + if not _state_id_registry: + return + + if after_max: + # Return max + 1 + next_id = max(_state_id_registry.keys()) + 1 + else: + # Find first gap starting from 0 + used_ids = set(_state_id_registry.keys()) + next_id = 0 + while next_id in used_ids: + next_id += 1 + + def _convert_reflex_loglevel_to_reflex_cli_loglevel( loglevel: constants.LogLevel, ) -> HostingLogLevel: diff --git a/reflex/state.py b/reflex/state.py index e7109fca4ce..66e939a844b 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -106,6 +106,9 @@ # Global registry: state_id -> state class (for duplicate detection) _state_id_registry: dict[int, type[BaseState]] = {} +# Characters used for minified names (valid JS identifiers) +MINIFIED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" + def _int_to_minified_name(state_id: int) -> str: """Convert integer state_id to minified name using base-54 encoding. @@ -123,22 +126,45 @@ def _int_to_minified_name(state_id: int) -> str: msg = f"state_id must be non-negative, got {state_id}" raise ValueError(msg) - # All possible chars for minified state name (valid JS identifiers) - chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" - base = len(chars) + base = len(MINIFIED_NAME_CHARS) if state_id == 0: - return chars[0] + return MINIFIED_NAME_CHARS[0] name = "" num = state_id while num > 0: - name = chars[num % base] + name + name = MINIFIED_NAME_CHARS[num % base] + name num //= base return name +def _minified_name_to_int(name: str) -> int: + """Convert minified name back to integer state_id. + + Args: + name: The minified state name (e.g., 'a', 'bU'). + + Returns: + The integer state_id. + + Raises: + ValueError: If the name contains invalid characters. + """ + base = len(MINIFIED_NAME_CHARS) + + result = 0 + for char in name: + index = MINIFIED_NAME_CHARS.find(char) + if index == -1: + msg = f"Invalid character '{char}' in minified name" + raise ValueError(msg) + result = result * base + index + + return result + + def _no_chain_background_task(state: BaseState, name: str, fn: Callable) -> Callable: """Protect against directly chaining a background task from another event handler. @@ -2716,7 +2742,7 @@ def wrapper() -> Component: LAST_RELOADED_KEY = "reflex_last_reloaded_on_error" -class FrontendEventExceptionState(State): +class FrontendEventExceptionState(State, state_id=1): """Substate for handling frontend exceptions.""" # If the frontend error message contains any of these strings, automatically reload the page. @@ -2769,7 +2795,7 @@ def handle_frontend_exception( ) -class UpdateVarsInternalState(State): +class UpdateVarsInternalState(State, state_id=2): """Substate for handling internal state var updates.""" async def update_vars_internal(self, vars: dict[str, Any]) -> None: @@ -2793,7 +2819,7 @@ async def update_vars_internal(self, vars: dict[str, Any]) -> None: setattr(var_state, var_name, value) -class OnLoadInternalState(State): +class OnLoadInternalState(State, state_id=3): """Substate for handling on_load event enumeration. This is a separate substate to avoid deserializing the entire state tree for every page navigation. diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 6efd006f1fa..780e57c7d49 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1253,7 +1253,7 @@ def _dynamic_state_event(name, val, **kwargs): prev_exp_val = "" for exp_index, exp_val in enumerate(exp_vals): on_load_internal = _event( - name=f"{state.get_full_name()}.{constants.CompileVars.ON_LOAD_INTERNAL.rpartition('.')[2]}", + name=f"{state.get_full_name()}.on_load_internal", val=exp_val, ) exp_router_data = { diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 3dcc72372ae..2dca8700f96 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -6,7 +6,16 @@ from reflex.environment import MinifyMode, environment from reflex.event import EVENT_ID_MARKER -from reflex.state import BaseState, _int_to_minified_name, _state_id_registry +from reflex.state import ( + BaseState, + FrontendEventExceptionState, + OnLoadInternalState, + State, + UpdateVarsInternalState, + _int_to_minified_name, + _minified_name_to_int, + _state_id_registry, +) from reflex.utils.exceptions import StateValueError @@ -409,3 +418,106 @@ class ConcreteState(MixinState, BaseState, state_id=601): @rx.event(event_id=0) def own_handler(self): pass + + +class TestMinifiedNameToInt: + """Tests for _minified_name_to_int reverse conversion.""" + + def test_single_char(self): + """Test single character conversion.""" + assert _minified_name_to_int("a") == 0 + assert _minified_name_to_int("b") == 1 + assert _minified_name_to_int("z") == 25 + assert _minified_name_to_int("A") == 26 + assert _minified_name_to_int("Z") == 51 + + def test_roundtrip(self): + """Test that int -> minified -> int roundtrip works.""" + for i in range(1000): + minified = _int_to_minified_name(i) + result = _minified_name_to_int(minified) + assert result == i, f"Roundtrip failed for {i}: {minified} -> {result}" + + def test_invalid_char_raises(self): + """Test that invalid characters raise ValueError.""" + with pytest.raises(ValueError, match="Invalid character"): + _minified_name_to_int("!") + + def test_state_lookup_returns_reflex_state(self): + """Test that looking up state_id=0 returns reflex's internal State.""" + # Re-register State after fixture clears the registry + _state_id_registry[0] = State + + assert 0 in _state_id_registry + state_cls = _state_id_registry[0] + assert state_cls is State + assert state_cls.__module__ == "reflex.state" + assert state_cls.__name__ == "State" + + def test_next_state_id_returns_1(self): + """Test that next available state_id is 1 (0 is used by internal State).""" + # Simulate reflex.state.State using state_id=0 + _state_id_registry[0] = State + + # Find first gap starting from 0 + used_ids = set(_state_id_registry.keys()) + next_id = 0 + while next_id in used_ids: + next_id += 1 + + assert next_id == 1 + + +class TestInternalStateIds: + """Tests for internal state classes having correct state_id values.""" + + def test_state_has_id_0(self): + """Test that the base State class has state_id=0.""" + assert State._state_id == 0 + + def test_frontend_exception_state_has_id_1(self): + """Test that FrontendEventExceptionState has state_id=1.""" + assert FrontendEventExceptionState._state_id == 1 + + def test_update_vars_internal_state_has_id_2(self): + """Test that UpdateVarsInternalState has state_id=2.""" + assert UpdateVarsInternalState._state_id == 2 + + def test_on_load_internal_state_has_id_3(self): + """Test that OnLoadInternalState has state_id=3.""" + assert OnLoadInternalState._state_id == 3 + + def test_internal_states_minified_names(self, reset_minify_mode): + """Test that internal states get correct minified names when enabled.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + + # Clear the lru_cache to get fresh results + State.get_name.cache_clear() + FrontendEventExceptionState.get_name.cache_clear() + UpdateVarsInternalState.get_name.cache_clear() + OnLoadInternalState.get_name.cache_clear() + + # State (id=0) -> "a" + assert State.get_name() == "a" + # FrontendEventExceptionState (id=1) -> "b" + assert FrontendEventExceptionState.get_name() == "b" + # UpdateVarsInternalState (id=2) -> "c" + assert UpdateVarsInternalState.get_name() == "c" + # OnLoadInternalState (id=3) -> "d" + assert OnLoadInternalState.get_name() == "d" + + def test_internal_states_full_names_when_disabled(self, reset_minify_mode): + """Test that internal states use full names when minification is disabled.""" + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + + # Clear the lru_cache to get fresh results + State.get_name.cache_clear() + FrontendEventExceptionState.get_name.cache_clear() + UpdateVarsInternalState.get_name.cache_clear() + OnLoadInternalState.get_name.cache_clear() + + # Should contain the class name pattern + assert "state" in State.get_name().lower() + assert "frontend" in FrontendEventExceptionState.get_name().lower() + assert "update" in UpdateVarsInternalState.get_name().lower() + assert "on_load" in OnLoadInternalState.get_name().lower() diff --git a/tests/units/test_state.py b/tests/units/test_state.py index ca41ac37abf..ef4a80b30ad 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -3094,7 +3094,7 @@ def index(): app=app, event=Event( token=token, - name=f"{state.get_name()}.{CompileVars.ON_LOAD_INTERNAL}", + name=f"{state.get_name()}.{OnLoadInternalState.get_name()}.on_load_internal", router_data={RouteVar.PATH: "/", RouteVar.ORIGIN: "/", RouteVar.QUERY: {}}, ), sid="sid", @@ -3147,7 +3147,7 @@ def index(): app=app, event=Event( token=token, - name=f"{state.get_full_name()}.{CompileVars.ON_LOAD_INTERNAL}", + name=f"{state.get_full_name()}.{OnLoadInternalState.get_name()}.on_load_internal", router_data={RouteVar.PATH: "/", RouteVar.ORIGIN: "/", RouteVar.QUERY: {}}, ), sid="sid", From fc4c5e18fe8121edc5a0f39164e76c0adbe5dc39 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 25 Jan 2026 01:02:44 +0100 Subject: [PATCH 07/28] wip cli --- reflex/reflex.py | 64 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index 9057f844b20..f321cc59d7b 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -853,14 +853,33 @@ def rename(new_name: str): ) def state_tree(output_json: bool): """Print the state tree with state_id's and event handlers with event_id's.""" + from typing import TypedDict + from reflex.event import EVENT_ID_MARKER from reflex.state import BaseState, State, _int_to_minified_name from reflex.utils import prerequisites + class EventHandlerData(TypedDict): + """Type for event handler data in state tree.""" + + name: str + event_id: int | None + minified_name: str | None + + class StateTreeData(TypedDict): + """Type for state tree data.""" + + name: str + full_name: str + state_id: int | None + minified_name: str | None + event_handlers: list[EventHandlerData] + substates: list[StateTreeData] + # Load the user's app to register all state classes prerequisites.get_app() - def build_state_tree(state_cls: type[BaseState]) -> dict: + def build_state_tree(state_cls: type[BaseState]) -> StateTreeData: """Recursively build state tree data. Args: @@ -901,7 +920,9 @@ def build_state_tree(state_cls: type[BaseState]) -> dict: "substates": substates, } - def print_state_tree(state_data: dict, prefix: str = "", is_last: bool = True): + def print_state_tree( + state_data: StateTreeData, prefix: str = "", is_last: bool = True + ): """Print a state and its children as a tree. Args: @@ -912,10 +933,14 @@ def print_state_tree(state_data: dict, prefix: str = "", is_last: bool = True): state_id = state_data["state_id"] minified = state_data["minified_name"] + # Print the state node + connector = "`-- " if is_last else "|-- " if state_id is not None: - f'{state_data["name"]} (state_id={state_id} -> "{minified}")' + console.log( + f'{prefix}{connector}{state_data["name"]} (state_id={state_id} -> "{minified}")' + ) else: - f"{state_data['name']} (state_id=None)" + console.log(f"{prefix}{connector}{state_data['name']} (state_id=None)") # Calculate new prefix for children child_prefix = prefix + (" " if is_last else "| ") @@ -926,15 +951,20 @@ def print_state_tree(state_data: dict, prefix: str = "", is_last: bool = True): has_substates = len(substates) > 0 if handlers: + console.log(f"{child_prefix}|-- Event Handlers:") handler_prefix = child_prefix + ("| " if has_substates else " ") for i, handler in enumerate(handlers): is_last_handler = i == len(handlers) - 1 + h_connector = "`-- " if is_last_handler else "|-- " event_id = handler["event_id"] if event_id is not None: - _ = ( - handler_prefix, - is_last_handler, - ) # silence unused variable warnings + console.log( + f'{handler_prefix}{h_connector}{handler["name"]} (event_id={event_id} -> "{handler["minified_name"]}")' + ) + else: + console.log( + f"{handler_prefix}{h_connector}{handler['name']} (event_id=None)" + ) # Print substates recursively for i, substate in enumerate(substates): @@ -944,8 +974,11 @@ def print_state_tree(state_data: dict, prefix: str = "", is_last: bool = True): tree_data = build_state_tree(State) if output_json: - pass + import json + + console.log(json.dumps(tree_data, indent=2)) else: + console.log("State Tree") print_state_tree(tree_data) @@ -975,10 +1008,12 @@ def state_lookup(output_json: bool, minified_path: str): try: state_id = _minified_name_to_int(part) except ValueError as err: + console.error(f"Invalid minified name: {part}") raise SystemExit(1) from err state_cls = _state_id_registry.get(state_id) if state_cls is None: + console.error(f"No state registered with state_id={state_id}") raise SystemExit(1) result_parts.append({ @@ -990,11 +1025,13 @@ def state_lookup(output_json: bool, minified_path: str): }) if output_json: - pass + import json + + console.log(json.dumps(result_parts, indent=2)) else: # Simple output: module.ClassName for each part - for _info in result_parts: - pass + for info in result_parts: + console.log(f"{info['module']}.{info['class']}") @cli.command(name="state-next-id") @@ -1013,6 +1050,7 @@ def state_next_id(after_max: bool): prerequisites.get_app() if not _state_id_registry: + console.log("0") return if after_max: @@ -1025,6 +1063,8 @@ def state_next_id(after_max: bool): while next_id in used_ids: next_id += 1 + console.log(str(next_id)) + def _convert_reflex_loglevel_to_reflex_cli_loglevel( loglevel: constants.LogLevel, From 6d13bec56cb93e2b088db11c137f1137588efaef Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 25 Jan 2026 01:11:25 +0100 Subject: [PATCH 08/28] wtf prettier --- reflex/.templates/web/utils/state.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index e46501929e5..ff75874d905 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -58,10 +58,10 @@ export const generateUUID = () => { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { let r = Math.random() * 16; if (d > 0) { - r = (d + r) % 16 | 0; + r = ((d + r) % 16) | 0; d = Math.floor(d / 16); } else { - r = (d2 + r) % 16 | 0; + r = ((d2 + r) % 16) | 0; d2 = Math.floor(d2 / 16); } return (c == "x" ? r : (r & 0x7) | 0x8).toString(16); From db97e3af6c4819a86e9fa904d84e80a6cc3e7a38 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 28 Jan 2026 22:06:29 +0100 Subject: [PATCH 09/28] update pyi_hashes --- pyi_hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 185f64eccae..a8f4208952e 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -113,7 +113,7 @@ "reflex/components/react_player/video.pyi": "998671c06103d797c554d9278eb3b2a0", "reflex/components/react_router/dom.pyi": "3042fa630b7e26a7378fe045d7fbf4af", "reflex/components/recharts/__init__.pyi": "6ee7f1ca2c0912f389ba6f3251a74d99", - "reflex/components/recharts/cartesian.pyi": "cfca4f880239ffaecdf9fb4c7c8caed5", + "reflex/components/recharts/cartesian.pyi": "642e32b6bb3dd709b2faa726833dc701", "reflex/components/recharts/charts.pyi": "013036b9c00ad85a570efdb813c1bc40", "reflex/components/recharts/general.pyi": "d87ff9b85b2a204be01753690df4fb11", "reflex/components/recharts/polar.pyi": "ad24bd37c6acc0bc9bd4ac01af3ffe49", From 6b4c5fa88de5e41170491f58b5eb5a39e44576da Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 28 Jan 2026 23:05:15 +0100 Subject: [PATCH 10/28] unique per sibling --- reflex/reflex.py | 116 ++++++++++++++++++------ reflex/state.py | 39 ++++----- tests/integration/test_minification.py | 10 +-- tests/units/test_minification.py | 117 +++++++++++++++---------- 4 files changed, 176 insertions(+), 106 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index f321cc59d7b..b74b44f3fd1 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -992,36 +992,42 @@ def print_state_tree( ) @click.argument("minified_path") def state_lookup(output_json: bool, minified_path: str): - """Lookup a state by its minified path (e.g., 'a.bU').""" - from reflex.state import _minified_name_to_int, _state_id_registry + """Lookup a state by its minified path (e.g., 'a.bU'). + + Walks the state tree from the root to resolve each segment. + """ + from reflex.state import State from reflex.utils import prerequisites # Load the user's app to register all state classes prerequisites.get_app() - # Parse the dotted path - parts = minified_path.split(".") + try: + State.get_class_substate(minified_path) + except ValueError: + msg = f"No state found for path: {minified_path}" + console.error(msg) + raise ValueError(msg) from None - # Resolve each part + # Build info for each ancestor segment + parts = minified_path.split(".") result_parts = [] - for part in parts: - try: - state_id = _minified_name_to_int(part) - except ValueError as err: - console.error(f"Invalid minified name: {part}") - raise SystemExit(1) from err - - state_cls = _state_id_registry.get(state_id) - if state_cls is None: - console.error(f"No state registered with state_id={state_id}") - raise SystemExit(1) - + current = State + result_parts.append({ + "minified": parts[0], + "state_id": current._state_id, + "module": current.__module__, + "class": current.__name__, + "full_name": current.get_full_name(), + }) + for part in parts[1:]: + current = current.get_class_substate(part) result_parts.append({ "minified": part, - "state_id": state_id, - "module": state_cls.__module__, - "class": state_cls.__name__, - "full_name": state_cls.get_full_name(), + "state_id": current._state_id, + "module": current.__module__, + "class": current.__name__, + "full_name": current.get_full_name(), }) if output_json: @@ -1034,6 +1040,48 @@ def state_lookup(output_json: bool, minified_path: str): console.log(f"{info['module']}.{info['class']}") +def _resolve_parent_state(parent: str): + """Resolve a parent argument to a state class. + + Accepts either a state path (minified like 'a.b' or full name) or a class + name (e.g., 'State', 'MySubState'). Tries path resolution first via + get_class_substate, then falls back to searching by class name. + + Args: + parent: Class name or state path identifying the parent state. + + Returns: + The resolved state class. + + Raises: + SystemExit: If the parent cannot be resolved. + """ + from reflex.state import BaseState, State + + # Try as a state path (minified or full name) + try: + return State.get_class_substate(parent) + except ValueError: + pass + + # Fall back to searching by class name + def _find_by_name(cls: type[BaseState], name: str) -> type[BaseState] | None: + if cls.__name__ == name: + return cls + for child in cls.class_subclasses: + result = _find_by_name(child, name) + if result is not None: + return result + return None + + result = _find_by_name(State, parent) + if result is not None: + return result + + console.error(f"No state found matching '{parent}'") + raise SystemExit(1) + + @cli.command(name="state-next-id") @loglevel_option @click.option( @@ -1041,24 +1089,34 @@ def state_lookup(output_json: bool, minified_path: str): is_flag=True, help="Return max(state_id) + 1 instead of first gap.", ) -def state_next_id(after_max: bool): - """Print the next available state_id.""" - from reflex.state import _state_id_registry +@click.argument("parent") +def state_next_id(after_max: bool, parent: str): + """Print the next available state_id under PARENT. + + PARENT can be a class name (e.g., 'State', 'MySubState') or a + minified path (e.g., 'a', 'a.b'). Auto-determined from input. + """ from reflex.utils import prerequisites # Load the user's app to register all state classes prerequisites.get_app() - if not _state_id_registry: + parent_cls = _resolve_parent_state(parent) + + # Collect sibling state_ids under the parent + used_ids = { + child._state_id + for child in parent_cls.class_subclasses + if child._state_id is not None + } + + if not used_ids: console.log("0") return if after_max: - # Return max + 1 - next_id = max(_state_id_registry.keys()) + 1 + next_id = max(used_ids) + 1 else: - # Find first gap starting from 0 - used_ids = set(_state_id_registry.keys()) next_id = 0 while next_id in used_ids: next_id += 1 diff --git a/reflex/state.py b/reflex/state.py index 39c35b200da..4d0bba77034 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -104,8 +104,6 @@ # For BaseState.get_var_value VAR_TYPE = TypeVar("VAR_TYPE") -# Global registry: state_id -> state class (for duplicate detection) -_state_id_registry: dict[int, type[BaseState]] = {} # Characters used for minified names (valid JS identifiers) MINIFIED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" @@ -607,22 +605,6 @@ def __init_subclass__( # Store state_id as class variable (only for non-mixins) cls._state_id = state_id - # Validate state_id if provided (check for duplicates) - if state_id is not None: - if state_id in _state_id_registry: - existing_cls = _state_id_registry[state_id] - # Allow re-registration if it's the same class (e.g., module reload) - existing_key = f"{existing_cls.__module__}.{existing_cls.__name__}" - new_key = f"{cls.__module__}.{cls.__name__}" - if existing_key != new_key: - msg = ( - f"Duplicate state_id={state_id}. Already used by " - f"'{existing_cls.__module__}.{existing_cls.__name__}', " - f"cannot be reused by '{cls.__module__}.{cls.__name__}'." - ) - raise StateValueError(msg) - _state_id_registry[state_id] = cls - # Handle locally-defined states for pickling. if "" in cls.__qualname__: cls._handle_local_def() @@ -649,6 +631,21 @@ def __init_subclass__( cls.inherited_vars = parent_state.vars cls.inherited_backend_vars = parent_state.backend_vars + # Check for duplicate state_id among siblings. + if state_id is not None: + for sibling in parent_state.class_subclasses: + if sibling._state_id is not None and sibling._state_id == state_id: + # Allow re-registration of the same class (e.g., module reload) + existing_key = f"{sibling.__module__}.{sibling.__name__}" + new_key = f"{cls.__module__}.{cls.__name__}" + if existing_key != new_key: + msg = ( + f"Duplicate state_id={state_id} among siblings of " + f"'{parent_state.__name__}': already used by " + f"'{sibling.__name__}', cannot be reused by '{cls.__name__}'." + ) + raise StateValueError(msg) + # Check if another substate class with the same name has already been defined. if cls.get_name() in {c.get_name() for c in parent_state.class_subclasses}: # This should not happen, since we have added module prefix to state names in #3214 @@ -2746,7 +2743,7 @@ def wrapper() -> Component: LAST_RELOADED_KEY = "reflex_last_reloaded_on_error" -class FrontendEventExceptionState(State, state_id=1): +class FrontendEventExceptionState(State, state_id=0): """Substate for handling frontend exceptions.""" # If the frontend error message contains any of these strings, automatically reload the page. @@ -2799,7 +2796,7 @@ def handle_frontend_exception( ) -class UpdateVarsInternalState(State, state_id=2): +class UpdateVarsInternalState(State, state_id=1): """Substate for handling internal state var updates.""" 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: setattr(var_state, var_name, value) -class OnLoadInternalState(State, state_id=3): +class OnLoadInternalState(State, state_id=2): """Substate for handling on_load event enumeration. This is a separate substate to avoid deserializing the entire state tree for every page navigation. diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py index b60c7f16260..cb01d555b41 100644 --- a/tests/integration/test_minification.py +++ b/tests/integration/test_minification.py @@ -11,7 +11,7 @@ from selenium.webdriver.common.by import By from reflex.environment import MinifyMode, environment -from reflex.state import _int_to_minified_name, _state_id_registry +from reflex.state import _int_to_minified_name from reflex.testing import AppHarness if TYPE_CHECKING: @@ -98,14 +98,6 @@ def index() -> rx.Component: app.add_page(index) -@pytest.fixture(autouse=True) -def reset_state_registry(): - """Reset the state_id registry before and after each test.""" - _state_id_registry.clear() - yield - _state_id_registry.clear() - - @pytest.fixture def minify_disabled_app( app_harness_env: type[AppHarness], diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 2dca8700f96..49db957ea51 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -14,19 +14,10 @@ UpdateVarsInternalState, _int_to_minified_name, _minified_name_to_int, - _state_id_registry, ) from reflex.utils.exceptions import StateValueError -@pytest.fixture(autouse=True) -def reset_state_registry(): - """Reset the state_id registry before and after each test.""" - _state_id_registry.clear() - yield - _state_id_registry.clear() - - @pytest.fixture def reset_minify_mode(): """Reset minify modes to DISABLED after each test.""" @@ -79,8 +70,6 @@ class TestState(BaseState, state_id=100): pass assert TestState._state_id == 100 - assert 100 in _state_id_registry - assert _state_id_registry[100] is TestState def test_state_without_id(self): """Test that a state can be created without state_id.""" @@ -90,17 +79,45 @@ class TestState(BaseState): assert TestState._state_id is None - def test_duplicate_state_id_raises(self): - """Test that duplicate state_id raises StateValueError.""" + def test_duplicate_state_id_among_siblings_raises(self): + """Test that duplicate state_id among siblings raises StateValueError.""" - class FirstState(BaseState, state_id=200): + class ParentState(BaseState, state_id=200): pass - with pytest.raises(StateValueError, match="Duplicate state_id=200"): + class FirstChild(ParentState, state_id=10): + pass + + with pytest.raises(StateValueError, match="Duplicate state_id=10"): - class SecondState(BaseState, state_id=200): + class SecondChild(ParentState, state_id=10): pass + def test_same_state_id_across_branches_allowed(self): + """Test that the same state_id can be used in different branches.""" + + class Root(BaseState, state_id=210): + pass + + class BranchA(Root, state_id=1): + pass + + class BranchB(Root, state_id=2): + pass + + class LeafA(BranchA, state_id=5): + pass + + class LeafB(BranchB, state_id=5): # same state_id=5, different parent -- OK! + pass + + # Both should succeed - state_id is per-parent (sibling uniqueness) + assert LeafA._state_id == 5 + assert LeafB._state_id == 5 + # But they have different full names + assert LeafA.get_parent_state() is BranchA + assert LeafB.get_parent_state() is BranchB + class TestGetNameMinification: """Tests for get_name with minification modes.""" @@ -443,29 +460,35 @@ def test_invalid_char_raises(self): with pytest.raises(ValueError, match="Invalid character"): _minified_name_to_int("!") - def test_state_lookup_returns_reflex_state(self): - """Test that looking up state_id=0 returns reflex's internal State.""" - # Re-register State after fixture clears the registry - _state_id_registry[0] = State + def test_state_has_state_id_zero(self): + """Test that the root State class has state_id=0.""" + assert State._state_id == 0 + assert State.__module__ == "reflex.state" + assert State.__name__ == "State" - assert 0 in _state_id_registry - state_cls = _state_id_registry[0] - assert state_cls is State - assert state_cls.__module__ == "reflex.state" - assert state_cls.__name__ == "State" + def test_next_sibling_state_id(self): + """Test finding next available state_id among siblings.""" - def test_next_state_id_returns_1(self): - """Test that next available state_id is 1 (0 is used by internal State).""" - # Simulate reflex.state.State using state_id=0 - _state_id_registry[0] = State + class Parent(BaseState, state_id=700): + pass + + class Child0(Parent, state_id=0): + pass + + class Child1(Parent, state_id=1): + pass - # Find first gap starting from 0 - used_ids = set(_state_id_registry.keys()) + # Find first gap starting from 0 among Parent's children + used_ids = { + child._state_id + for child in Parent.class_subclasses + if child._state_id is not None + } next_id = 0 while next_id in used_ids: next_id += 1 - assert next_id == 1 + assert next_id == 2 class TestInternalStateIds: @@ -475,17 +498,17 @@ def test_state_has_id_0(self): """Test that the base State class has state_id=0.""" assert State._state_id == 0 - def test_frontend_exception_state_has_id_1(self): - """Test that FrontendEventExceptionState has state_id=1.""" - assert FrontendEventExceptionState._state_id == 1 + def test_frontend_exception_state_has_id_0(self): + """Test that FrontendEventExceptionState has state_id=0.""" + assert FrontendEventExceptionState._state_id == 0 - def test_update_vars_internal_state_has_id_2(self): - """Test that UpdateVarsInternalState has state_id=2.""" - assert UpdateVarsInternalState._state_id == 2 + def test_update_vars_internal_state_has_id_1(self): + """Test that UpdateVarsInternalState has state_id=1.""" + assert UpdateVarsInternalState._state_id == 1 - def test_on_load_internal_state_has_id_3(self): - """Test that OnLoadInternalState has state_id=3.""" - assert OnLoadInternalState._state_id == 3 + def test_on_load_internal_state_has_id_2(self): + """Test that OnLoadInternalState has state_id=2.""" + assert OnLoadInternalState._state_id == 2 def test_internal_states_minified_names(self, reset_minify_mode): """Test that internal states get correct minified names when enabled.""" @@ -499,12 +522,12 @@ def test_internal_states_minified_names(self, reset_minify_mode): # State (id=0) -> "a" assert State.get_name() == "a" - # FrontendEventExceptionState (id=1) -> "b" - assert FrontendEventExceptionState.get_name() == "b" - # UpdateVarsInternalState (id=2) -> "c" - assert UpdateVarsInternalState.get_name() == "c" - # OnLoadInternalState (id=3) -> "d" - assert OnLoadInternalState.get_name() == "d" + # FrontendEventExceptionState (id=0) -> "a" + assert FrontendEventExceptionState.get_name() == "a" + # UpdateVarsInternalState (id=1) -> "b" + assert UpdateVarsInternalState.get_name() == "b" + # OnLoadInternalState (id=2) -> "c" + assert OnLoadInternalState.get_name() == "c" def test_internal_states_full_names_when_disabled(self, reset_minify_mode): """Test that internal states use full names when minification is disabled.""" From 1647c1efd3ed6ed7ef29757f547456f75cf37ab7 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 11:25:15 +0100 Subject: [PATCH 11/28] fix test state ids --- tests/integration/test_minification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py index cb01d555b41..2685225530f 100644 --- a/tests/integration/test_minification.py +++ b/tests/integration/test_minification.py @@ -122,8 +122,8 @@ def minify_disabled_app( app_name="minify_disabled", app_source=partial( MinificationApp, - root_state_id=0, - sub_state_id=1, + root_state_id=3, + sub_state_id=4, increment_event_id=0, update_message_event_id=0, ), From e375b7e030549b6c90bd316c09723bff8dc9b76c Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 11:48:33 +0100 Subject: [PATCH 12/28] add best-effort mode for event ids --- reflex/environment.py | 24 ++- reflex/state.py | 43 +++-- reflex/utils/format.py | 4 +- tests/integration/test_minification.py | 18 +- tests/units/test_minification.py | 226 +++++++++++++++++++++++-- 5 files changed, 269 insertions(+), 46 deletions(-) diff --git a/reflex/environment.py b/reflex/environment.py index 87fcf4484d7..d0a48208150 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -487,12 +487,22 @@ class PerformanceMode(enum.Enum): @enum.unique -class MinifyMode(enum.Enum): - """Mode for state/event name minification.""" +class StateMinifyMode(enum.Enum): + """Mode for state name minification.""" DISABLED = "disabled" # Never minify names (default) - ENABLED = "enabled" # Minify items that have explicit IDs - ENFORCE = "enforce" # Require all items to have explicit IDs + ENABLED = "enabled" # Minify states that have explicit state_id + ENFORCE = "enforce" # Require all states to have explicit state_id + + +@enum.unique +class EventMinifyMode(enum.Enum): + """Mode for event handler name minification.""" + + DISABLED = "disabled" # Never minify names (default) + ENABLED = "enabled" # Minify handlers that have explicit event_id + ENFORCE = "enforce" # Require all handlers to have explicit event_id + BEST_EFFORT = "best_effort" # Auto-assign IDs to handlers without explicit IDs class ExecutorType(enum.Enum): @@ -698,10 +708,10 @@ class EnvironmentVariables: REFLEX_STATE_SIZE_LIMIT: EnvVar[int] = env_var(1000) # State name minification mode: disabled, enabled, or enforce. - REFLEX_MINIFY_STATES: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + REFLEX_MINIFY_STATES: EnvVar[StateMinifyMode] = env_var(StateMinifyMode.DISABLED) - # Event handler name minification mode: disabled, enabled, or enforce. - REFLEX_MINIFY_EVENTS: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + # Event handler name minification mode: disabled, enabled, enforce, or best_effort. + REFLEX_MINIFY_EVENTS: EnvVar[EventMinifyMode] = env_var(EventMinifyMode.DISABLED) # Whether to use the turbopack bundler. REFLEX_USE_TURBOPACK: EnvVar[bool] = env_var(False) diff --git a/reflex/state.py b/reflex/state.py index 4d0bba77034..3bcba70fd9d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -10,6 +10,7 @@ import datetime import functools import inspect +import operator import pickle import re import sys @@ -741,7 +742,9 @@ def __init_subclass__( # Build event_id registry and validate uniqueness within this state class cls._event_id_to_name = {} - missing_event_ids: list[str] = [] + handlers_with_id: list[tuple[str, Callable[..., Any], int]] = [] + handlers_without_id: list[tuple[str, Callable[..., Any]]] = [] + for name, fn in events.items(): event_id = getattr(fn, EVENT_ID_MARKER, None) if event_id is not None: @@ -753,22 +756,38 @@ def __init_subclass__( ) raise StateValueError(msg) cls._event_id_to_name[event_id] = name + handlers_with_id.append((name, fn, event_id)) else: - missing_event_ids.append(name) + handlers_without_id.append((name, fn)) - # In ENFORCE mode, all event handlers must have event_id - from reflex.environment import MinifyMode + from reflex.environment import EventMinifyMode - if ( - environment.REFLEX_MINIFY_EVENTS.get() == MinifyMode.ENFORCE - and missing_event_ids - ): + minify_mode = environment.REFLEX_MINIFY_EVENTS.get() + + # In ENFORCE mode, all event handlers must have event_id + if minify_mode == EventMinifyMode.ENFORCE and handlers_without_id: + missing = [name for name, _ in handlers_without_id] msg = ( f"State '{cls.__name__}' in ENFORCE mode: event handlers " - f"{missing_event_ids} are missing required event_id." + f"{missing} are missing required event_id." ) raise StateValueError(msg) + # In BEST_EFFORT mode, auto-assign IDs to handlers without explicit IDs + if minify_mode == EventMinifyMode.BEST_EFFORT and handlers_without_id: + # Find the highest user-defined event_id + max_explicit_id = max((eid for _, _, eid in handlers_with_id), default=-1) + + # Sort handlers without IDs alphabetically by name for deterministic ordering + handlers_without_id.sort(key=operator.itemgetter(0)) + + # Assign IDs starting from max_explicit_id + 1 + next_id = max_explicit_id + 1 + for name, fn in handlers_without_id: + setattr(fn, EVENT_ID_MARKER, next_id) + cls._event_id_to_name[next_id] = name + next_id += 1 + # Initialize per-class var dependency tracking. cls._var_dependencies = {} cls._init_var_dependency_dicts() @@ -1124,7 +1143,7 @@ def get_name(cls) -> str: Raises: StateValueError: If ENFORCE mode is set and state_id is missing. """ - from reflex.environment import MinifyMode + from reflex.environment import StateMinifyMode from reflex.utils.exceptions import StateValueError module = cls.__module__.replace(".", "___") @@ -1132,14 +1151,14 @@ def get_name(cls) -> str: minify_mode = environment.REFLEX_MINIFY_STATES.get() - if minify_mode == MinifyMode.DISABLED: + if minify_mode == StateMinifyMode.DISABLED: return full_name if cls._state_id is not None: return _int_to_minified_name(cls._state_id) # state_id not set - if minify_mode == MinifyMode.ENFORCE: + if minify_mode == StateMinifyMode.ENFORCE: msg = ( f"State '{cls.__module__}.{cls.__name__}' is missing required state_id. " f"Add state_id parameter: class {cls.__name__}(rx.State, state_id=N)" diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 6e094049103..23249f07320 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -448,7 +448,7 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: Returns: The state and function name (possibly minified based on REFLEX_MINIFY_EVENTS). """ - from reflex.environment import MinifyMode, environment + from reflex.environment import EventMinifyMode, environment from reflex.event import EVENT_ID_MARKER from reflex.state import State, _int_to_minified_name @@ -470,7 +470,7 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: # Check for event_id minification mode = environment.REFLEX_MINIFY_EVENTS.get() - if mode != MinifyMode.DISABLED: + if mode != EventMinifyMode.DISABLED: event_id = getattr(handler.fn, EVENT_ID_MARKER, None) if event_id is not None: name = _int_to_minified_name(event_id) diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py index 2685225530f..1524e1d285a 100644 --- a/tests/integration/test_minification.py +++ b/tests/integration/test_minification.py @@ -10,7 +10,7 @@ import pytest from selenium.webdriver.common.by import By -from reflex.environment import MinifyMode, environment +from reflex.environment import EventMinifyMode, StateMinifyMode, environment from reflex.state import _int_to_minified_name from reflex.testing import AppHarness @@ -114,8 +114,8 @@ def minify_disabled_app( """ os.environ["REFLEX_MINIFY_STATES"] = "disabled" os.environ["REFLEX_MINIFY_EVENTS"] = "disabled" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) with app_harness_env.create( root=tmp_path_factory.mktemp("minify_disabled"), @@ -133,8 +133,8 @@ def minify_disabled_app( # Cleanup os.environ.pop("REFLEX_MINIFY_STATES", None) os.environ.pop("REFLEX_MINIFY_EVENTS", None) - environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) @pytest.fixture @@ -153,8 +153,8 @@ def minify_enabled_app( """ os.environ["REFLEX_MINIFY_STATES"] = "enabled" os.environ["REFLEX_MINIFY_EVENTS"] = "enabled" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) with app_harness_env.create( root=tmp_path_factory.mktemp("minify_enabled"), @@ -172,8 +172,8 @@ def minify_enabled_app( # Cleanup os.environ.pop("REFLEX_MINIFY_STATES", None) os.environ.pop("REFLEX_MINIFY_EVENTS", None) - environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) @pytest.fixture diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 49db957ea51..b59343ac693 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -4,7 +4,7 @@ import pytest -from reflex.environment import MinifyMode, environment +from reflex.environment import EventMinifyMode, StateMinifyMode, environment from reflex.event import EVENT_ID_MARKER from reflex.state import ( BaseState, @@ -124,7 +124,7 @@ class TestGetNameMinification: def test_disabled_mode_uses_full_name(self, reset_minify_mode): """Test DISABLED mode always uses full name even with state_id.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) class TestState(BaseState, state_id=300): pass @@ -139,7 +139,7 @@ class TestState(BaseState, state_id=300): def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): """Test ENABLED mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) class TestState(BaseState, state_id=301): pass @@ -152,7 +152,7 @@ class TestState(BaseState, state_id=301): def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): """Test ENABLED mode without state_id uses full name.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) class TestState(BaseState): pass @@ -166,7 +166,7 @@ class TestState(BaseState): def test_enforce_mode_without_id_raises(self, reset_minify_mode): """Test ENFORCE mode without state_id raises error during class definition.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) # Error is raised during class definition because get_name() is called # during __init_subclass__ @@ -177,7 +177,7 @@ class TestState(BaseState): def test_enforce_mode_with_id_uses_minified(self, reset_minify_mode): """Test ENFORCE mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) class TestState(BaseState, state_id=302): pass @@ -194,7 +194,7 @@ class TestMixinState: def test_mixin_no_state_id_required(self, reset_minify_mode): """Test that mixin states don't require state_id even in ENFORCE mode.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENFORCE) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) class MixinState(BaseState, mixin=True): pass @@ -292,7 +292,7 @@ def test_disabled_mode_uses_full_name(self, reset_minify_mode): import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) class TestState(BaseState, state_id=500): @rx.event(event_id=0) @@ -310,7 +310,7 @@ def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) class TestState(BaseState, state_id=501): @rx.event(event_id=5) @@ -330,7 +330,7 @@ def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) class TestState(BaseState, state_id=502): @rx.event @@ -348,7 +348,7 @@ def test_enforce_mode_without_event_id_raises(self, reset_minify_mode): """Test ENFORCE mode without event_id raises error during class definition.""" import reflex as rx - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENFORCE) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENFORCE) with pytest.raises(StateValueError, match="missing required event_id"): @@ -362,7 +362,7 @@ def test_enforce_mode_with_event_id_works(self, reset_minify_mode): import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENFORCE) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENFORCE) class TestState(BaseState, state_id=504): @rx.event(event_id=0) @@ -386,7 +386,7 @@ def test_mixin_event_id_preserved(self, reset_minify_mode): import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) class MixinState(BaseState, mixin=True): @rx.event(event_id=10) @@ -422,7 +422,7 @@ def test_mixin_event_id_conflict_raises(self, reset_minify_mode): """Test that conflicting event_ids from mixin and concrete state raises error.""" import reflex as rx - environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) class MixinState(BaseState, mixin=True): @rx.event(event_id=0) @@ -512,7 +512,7 @@ def test_on_load_internal_state_has_id_2(self): def test_internal_states_minified_names(self, reset_minify_mode): """Test that internal states get correct minified names when enabled.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.ENABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) # Clear the lru_cache to get fresh results State.get_name.cache_clear() @@ -531,7 +531,7 @@ def test_internal_states_minified_names(self, reset_minify_mode): def test_internal_states_full_names_when_disabled(self, reset_minify_mode): """Test that internal states use full names when minification is disabled.""" - environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) # Clear the lru_cache to get fresh results State.get_name.cache_clear() @@ -544,3 +544,197 @@ def test_internal_states_full_names_when_disabled(self, reset_minify_mode): assert "frontend" in FrontendEventExceptionState.get_name().lower() assert "update" in UpdateVarsInternalState.get_name().lower() assert "on_load" in OnLoadInternalState.get_name().lower() + + +class TestBestEffortMode: + """Tests for BEST_EFFORT event minification mode.""" + + def test_best_effort_assigns_ids_to_handlers_without_explicit_id( + self, reset_minify_mode + ): + """Test that BEST_EFFORT mode assigns event_ids to handlers without explicit IDs.""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=700): + @rx.event + def handler_a(self): + pass + + @rx.event + def handler_b(self): + pass + + # Both handlers should have event_ids assigned + assert 0 in TestState._event_id_to_name + assert 1 in TestState._event_id_to_name + # Should be assigned alphabetically + assert TestState._event_id_to_name[0] == "handler_a" + assert TestState._event_id_to_name[1] == "handler_b" + + def test_best_effort_starts_after_highest_explicit_id(self, reset_minify_mode): + """Test that BEST_EFFORT mode starts assigning IDs after the highest explicit ID.""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=701): + @rx.event(event_id=5) + def explicit_handler(self): + pass + + @rx.event(event_id=10) + def another_explicit(self): + pass + + @rx.event + def auto_handler(self): + pass + + # Explicit handlers should keep their IDs + assert TestState._event_id_to_name[5] == "explicit_handler" + assert TestState._event_id_to_name[10] == "another_explicit" + # Auto-assigned handler should start at max_explicit_id + 1 = 11 + assert TestState._event_id_to_name[11] == "auto_handler" + + def test_best_effort_assigns_ids_alphabetically(self, reset_minify_mode): + """Test that BEST_EFFORT mode assigns IDs to handlers alphabetically by name.""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=702): + @rx.event + def zebra_handler(self): + pass + + @rx.event + def alpha_handler(self): + pass + + @rx.event + def middle_handler(self): + pass + + # Should be assigned alphabetically: alpha, middle, zebra + assert TestState._event_id_to_name[0] == "alpha_handler" + assert TestState._event_id_to_name[1] == "middle_handler" + assert TestState._event_id_to_name[2] == "zebra_handler" + + def test_best_effort_with_no_explicit_ids(self, reset_minify_mode): + """Test that BEST_EFFORT mode works with no explicit IDs (starts at 0).""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=703): + @rx.event + def first_handler(self): + pass + + @rx.event + def second_handler(self): + pass + + # Should start at 0 since no explicit IDs + assert TestState._event_id_to_name[0] == "first_handler" + assert TestState._event_id_to_name[1] == "second_handler" + + def test_best_effort_minifies_all_handlers(self, reset_minify_mode): + """Test that BEST_EFFORT mode minifies all handlers in format output.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=704): + @rx.event(event_id=0) + def explicit_handler(self): + pass + + @rx.event + def auto_handler(self): + pass + + TestState.get_name.cache_clear() + + explicit = TestState.event_handlers["explicit_handler"] + auto = TestState.event_handlers["auto_handler"] + + _, explicit_name = get_event_handler_parts(explicit) + _, auto_name = get_event_handler_parts(auto) + + # Both should be minified + assert explicit_name == _int_to_minified_name(0) # 'a' + assert auto_name == _int_to_minified_name(1) # 'b' + + def test_best_effort_skips_gaps_in_explicit_ids(self, reset_minify_mode): + """Test that BEST_EFFORT mode skips gaps in explicit IDs (doesn't fill them).""" + import reflex as rx + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=705): + @rx.event(event_id=0) + def handler_zero(self): + pass + + @rx.event(event_id=5) + def handler_five(self): + pass + + @rx.event(event_id=10) + def handler_ten(self): + pass + + @rx.event + def auto_handler(self): + pass + + # Explicit handlers keep their IDs + assert TestState._event_id_to_name[0] == "handler_zero" + assert TestState._event_id_to_name[5] == "handler_five" + assert TestState._event_id_to_name[10] == "handler_ten" + # Auto-assigned starts at 11, not filling gaps at 1-4 or 6-9 + assert TestState._event_id_to_name[11] == "auto_handler" + # Verify gaps are not filled + assert 1 not in TestState._event_id_to_name + assert 6 not in TestState._event_id_to_name + + def test_best_effort_mixed_explicit_and_auto(self, reset_minify_mode): + """Test BEST_EFFORT with a mix of explicit and auto-assigned handlers.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) + + class TestState(BaseState, state_id=706): + @rx.event(event_id=3) + def explicit_three(self): + pass + + @rx.event + def auto_alpha(self): + pass + + @rx.event + def auto_beta(self): + pass + + TestState.get_name.cache_clear() + + # Check registry + assert TestState._event_id_to_name[3] == "explicit_three" + assert TestState._event_id_to_name[4] == "auto_alpha" # alphabetically first + assert TestState._event_id_to_name[5] == "auto_beta" # alphabetically second + + # Check that all handlers are minified correctly + for name, expected_id in [ + ("explicit_three", 3), + ("auto_alpha", 4), + ("auto_beta", 5), + ]: + handler = TestState.event_handlers[name] + _, minified = get_event_handler_parts(handler) + assert minified == _int_to_minified_name(expected_id) From a94c98bb4eb61f6ef993d54315abd65198151449 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 13:47:16 +0100 Subject: [PATCH 13/28] minify json poc --- reflex/environment.py | 25 - reflex/event.py | 7 - reflex/minify.py | 553 +++++++++++++++ reflex/reflex.py | 330 +++++---- reflex/state.py | 205 +----- reflex/utils/format.py | 28 +- tests/integration/test_minification.py | 158 ++--- tests/units/test_minification.py | 903 ++++++++----------------- 8 files changed, 1181 insertions(+), 1028 deletions(-) create mode 100644 reflex/minify.py diff --git a/reflex/environment.py b/reflex/environment.py index d0a48208150..279fc5f60c1 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -486,25 +486,6 @@ class PerformanceMode(enum.Enum): OFF = "off" -@enum.unique -class StateMinifyMode(enum.Enum): - """Mode for state name minification.""" - - DISABLED = "disabled" # Never minify names (default) - ENABLED = "enabled" # Minify states that have explicit state_id - ENFORCE = "enforce" # Require all states to have explicit state_id - - -@enum.unique -class EventMinifyMode(enum.Enum): - """Mode for event handler name minification.""" - - DISABLED = "disabled" # Never minify names (default) - ENABLED = "enabled" # Minify handlers that have explicit event_id - ENFORCE = "enforce" # Require all handlers to have explicit event_id - BEST_EFFORT = "best_effort" # Auto-assign IDs to handlers without explicit IDs - - class ExecutorType(enum.Enum): """Executor for compiling the frontend.""" @@ -707,12 +688,6 @@ class EnvironmentVariables: # The maximum size of the reflex state in kilobytes. REFLEX_STATE_SIZE_LIMIT: EnvVar[int] = env_var(1000) - # State name minification mode: disabled, enabled, or enforce. - REFLEX_MINIFY_STATES: EnvVar[StateMinifyMode] = env_var(StateMinifyMode.DISABLED) - - # Event handler name minification mode: disabled, enabled, enforce, or best_effort. - REFLEX_MINIFY_EVENTS: EnvVar[EventMinifyMode] = env_var(EventMinifyMode.DISABLED) - # Whether to use the turbopack bundler. REFLEX_USE_TURBOPACK: EnvVar[bool] = env_var(False) diff --git a/reflex/event.py b/reflex/event.py index 8e58e8d2162..825370cfa2f 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -2338,7 +2338,6 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, - event_id: int | None = None, ) -> Callable[ [Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]] # pyright: ignore [reportInvalidTypeVarUse] ]: ... @@ -2354,7 +2353,6 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, - event_id: int | None = None, ) -> EventCallback[Unpack[P]]: ... def __new__( @@ -2367,7 +2365,6 @@ def __new__( throttle: int | None = None, debounce: int | None = None, temporal: bool | None = None, - event_id: int | None = None, ) -> ( EventCallback[Unpack[P]] | Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]] @@ -2382,7 +2379,6 @@ def __new__( throttle: Throttle the event handler to limit calls (in milliseconds). debounce: Debounce the event handler to delay calls (in milliseconds). temporal: Whether the event should be dropped when the backend is down. - event_id: Optional integer ID for deterministic minified event names. Raises: TypeError: If background is True and the function is not a coroutine or async generator. # noqa: DAR402 @@ -2470,9 +2466,6 @@ def wrapper( event_actions = _build_event_actions() if event_actions: setattr(func, EVENT_ACTIONS_MARKER, event_actions) - # Store event_id on the function for minification - if event_id is not None: - setattr(func, EVENT_ID_MARKER, event_id) return func # pyright: ignore [reportReturnType] if func is not None: diff --git a/reflex/minify.py b/reflex/minify.py new file mode 100644 index 00000000000..cc9a9acdc00 --- /dev/null +++ b/reflex/minify.py @@ -0,0 +1,553 @@ +"""Minification configuration for state and event names. + +This module provides centralized ID management for minifying state and event handler +names. The configuration is stored in a `minify.json` file at the project root. +""" + +from __future__ import annotations + +import functools +import json +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from reflex.state import BaseState + +# File name for the minify configuration +MINIFY_JSON = "minify.json" + +# Current schema version +SCHEMA_VERSION = 1 + + +class MinifyConfig(TypedDict): + """Schema for minify.json file. + + Version 2 format: + - states: dict mapping state_path -> minified_name (string) + - events: dict mapping state_path -> {handler_name -> minified_name} + """ + + version: int + states: dict[str, str] # state_path -> minified_name + events: dict[str, dict[str, str]] # state_path -> {handler_name -> minified_name} + + +def _get_minify_json_path() -> Path: + """Get the path to the minify.json file. + + Returns: + Path to minify.json in the current working directory. + """ + return Path.cwd() / MINIFY_JSON + + +def _load_minify_config_uncached() -> MinifyConfig | None: + """Load minify configuration from minify.json. + + Returns: + The parsed configuration, or None if file doesn't exist. + + Raises: + ValueError: If the file exists but has an invalid format. + """ + path = _get_minify_json_path() + if not path.exists(): + return None + + try: + with path.open(encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + msg = f"Invalid JSON in {MINIFY_JSON}: {e}" + raise ValueError(msg) from e + + # Validate schema version + version = data.get("version") + if version != SCHEMA_VERSION: + msg = ( + f"Unsupported {MINIFY_JSON} version: {version}. Expected {SCHEMA_VERSION}." + ) + raise ValueError(msg) + + # Validate required keys + if "states" not in data or not isinstance(data["states"], dict): + msg = f"Invalid {MINIFY_JSON}: 'states' must be a dictionary." + raise ValueError(msg) + if "events" not in data or not isinstance(data["events"], dict): + msg = f"Invalid {MINIFY_JSON}: 'events' must be a dictionary." + raise ValueError(msg) + + # Validate states: all values must be strings + for key, value in data["states"].items(): + if not isinstance(value, str): + msg = f"Invalid {MINIFY_JSON}: state '{key}' has non-string id: {value}" + raise ValueError(msg) + + # Validate events: must be dict of dicts with string values + for state_path, handlers in data["events"].items(): + if not isinstance(handlers, dict): + msg = f"Invalid {MINIFY_JSON}: events for '{state_path}' must be a dictionary." + raise ValueError(msg) + for handler_name, event_id in handlers.items(): + if not isinstance(event_id, str): + msg = f"Invalid {MINIFY_JSON}: event '{state_path}.{handler_name}' has non-string id: {event_id}" + raise ValueError(msg) + + return MinifyConfig( + version=data["version"], + states=data["states"], + events=data["events"], + ) + + +@functools.cache +def get_minify_config() -> MinifyConfig | None: + """Get the minify configuration, cached. + + This function is cached so the file is only read once per process. + + Returns: + The parsed configuration, or None if file doesn't exist. + """ + return _load_minify_config_uncached() + + +def is_minify_enabled() -> bool: + """Check if minification is enabled. + + Returns: + True if minify.json exists and is valid. + """ + return get_minify_config() is not None + + +def get_state_id(state_full_path: str) -> str | None: + """Get the minified ID for a state. + + Args: + state_full_path: The full path to the state (e.g., "myapp.state.AppState.UserState"). + + Returns: + The minified state name (e.g., "a", "ba") if configured, None otherwise. + """ + config = get_minify_config() + if config is None: + return None + return config["states"].get(state_full_path) + + +def get_event_id(state_full_path: str, handler_name: str) -> str | None: + """Get the minified ID for an event handler. + + Args: + state_full_path: The full path to the state. + handler_name: The name of the event handler. + + Returns: + The minified event name (e.g., "a", "ba") if configured, None otherwise. + """ + config = get_minify_config() + if config is None: + return None + state_events = config["events"].get(state_full_path) + if state_events is None: + return None + return state_events.get(handler_name) + + +def save_minify_config(config: MinifyConfig) -> None: + """Save minify configuration to minify.json. + + Args: + config: The configuration to save. + """ + path = _get_minify_json_path() + with path.open("w", encoding="utf-8") as f: + json.dump(config, f, indent=2, sort_keys=True) + f.write("\n") + + +def clear_config_cache() -> None: + """Clear the cached configuration. + + This should be called after modifying minify.json programmatically. + """ + get_minify_config.cache_clear() + + +# Base-54 encoding for minified names +# Using letters (a-z, A-Z) plus $ and _ which are valid JS identifier chars +_MINIFY_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" +_MINIFY_BASE = len(_MINIFY_CHARS) # 54 + + +def int_to_minified_name(id_: int) -> str: + """Convert integer ID to minified name using base-54 encoding. + + Args: + id_: The integer ID to convert. + + Returns: + A minified string representation. + + Raises: + ValueError: If id_ is negative. + """ + if id_ < 0: + msg = f"ID must be non-negative, got {id_}" + raise ValueError(msg) + + # Special case: 0 maps to 'a' + if id_ == 0: + return _MINIFY_CHARS[0] + + result = [] + num = id_ + while num > 0: + result.append(_MINIFY_CHARS[num % _MINIFY_BASE]) + num //= _MINIFY_BASE + + return "".join(reversed(result)) + + +def minified_name_to_int(name: str) -> int: + """Convert minified name back to integer ID. + + Args: + name: The minified string to convert. + + Returns: + The integer ID. + + Raises: + ValueError: If name contains invalid characters. + """ + result = 0 + for char in name: + idx = _MINIFY_CHARS.find(char) + if idx == -1: + msg = f"Invalid character in minified name: '{char}'" + raise ValueError(msg) + result = result * _MINIFY_BASE + idx + return result + + +def get_state_full_path(state_cls: type[BaseState]) -> str: + """Get the full path for a state class suitable for minify.json. + + This returns the module path plus class name hierarchy, which uniquely + identifies a state class. + + Args: + state_cls: The state class. + + Returns: + The full path string (e.g., "myapp.state.AppState.UserState"). + """ + # Build the path from module + class hierarchy + # Use __original_module__ if available (for dynamic states that get moved) + module = getattr(state_cls, "__original_module__", None) or state_cls.__module__ + parts = [module] + + # Get the class hierarchy from root to this class + class_hierarchy = [] + current: type[BaseState] | None = state_cls + while current is not None: + class_hierarchy.append(current.__name__) + current = current.get_parent_state() # type: ignore[union-attr] + + # Reverse to get root-to-leaf order + class_hierarchy.reverse() + + # Combine module and class hierarchy + parts.extend(class_hierarchy) + return ".".join(parts) + + +def collect_all_states( + root_state: type[BaseState], +) -> list[type[BaseState]]: + """Recursively collect all state classes starting from root. + + Args: + root_state: The root state class to start from. + + Returns: + List of all state classes in depth-first order. + """ + result = [root_state] + for substate in sorted(root_state.class_subclasses, key=lambda s: s.__name__): + result.extend(collect_all_states(substate)) + return result + + +def generate_minify_config(root_state: type[BaseState]) -> MinifyConfig: + """Generate a complete minify configuration for all states and events. + + Assigns minified names starting from 'a' for each scope (siblings get unique names), + sorted alphabetically by name for determinism. + + Args: + root_state: The root state class. + + Returns: + A complete MinifyConfig. + """ + states: dict[str, str] = {} + events: dict[str, dict[str, str]] = {} + + def process_state( + state_cls: type[BaseState], + sibling_counter: dict[type[BaseState] | None, int], + ) -> None: + """Process a state and its children recursively. + + Args: + state_cls: The state class to process. + sibling_counter: Counter for assigning sibling-unique IDs. + """ + parent = state_cls.get_parent_state() + + # Assign state ID (unique among siblings) + if parent not in sibling_counter: + sibling_counter[parent] = 0 + state_id = sibling_counter[parent] + sibling_counter[parent] += 1 + + # Store state minified name + state_path = get_state_full_path(state_cls) + states[state_path] = int_to_minified_name(state_id) + + # Assign event IDs for this state's handlers (sorted alphabetically) + handler_names = sorted(state_cls.event_handlers.keys()) + state_events: dict[str, str] = {} + for event_id, handler_name in enumerate(handler_names): + state_events[handler_name] = int_to_minified_name(event_id) + if state_events: + events[state_path] = state_events + + # Process children (sorted alphabetically) + children = sorted(state_cls.class_subclasses, key=lambda s: s.__name__) + for child in children: + process_state(child, sibling_counter) + + # Start processing from root + sibling_counter: dict[type[BaseState] | None, int] = {} + process_state(root_state, sibling_counter) + + return MinifyConfig( + version=SCHEMA_VERSION, + states=states, + events=events, + ) + + +def validate_minify_config( + config: MinifyConfig, + root_state: type[BaseState], +) -> tuple[list[str], list[str], list[str]]: + """Validate a minify configuration against the current state tree. + + Args: + config: The configuration to validate. + root_state: The root state class. + + Returns: + A tuple of (errors, warnings, missing_entries): + - errors: Critical issues (duplicate IDs, etc.) + - warnings: Non-critical issues (orphaned entries) + - missing_entries: States/events in code but not in config + """ + errors: list[str] = [] + + all_states = collect_all_states(root_state) + + # Check for duplicate state IDs among siblings + # Group states by parent path and check for duplicate minified names + parent_to_state_ids: dict[str | None, dict[str, list[str]]] = {} + for state_path, minified_name in config["states"].items(): + # Get parent path + parts = state_path.rsplit(".", 1) + parent_path = parts[0] if len(parts) > 1 else None + + if parent_path not in parent_to_state_ids: + parent_to_state_ids[parent_path] = {} + if minified_name not in parent_to_state_ids[parent_path]: + parent_to_state_ids[parent_path][minified_name] = [] + parent_to_state_ids[parent_path][minified_name].append(state_path) + + for parent_path, id_to_states in parent_to_state_ids.items(): + for minified_name, state_paths in id_to_states.items(): + if len(state_paths) > 1: + errors.append( + f"Duplicate state_id='{minified_name}' under '{parent_path or 'root'}': " + f"{state_paths}" + ) + + # Check for duplicate event IDs within same state + for state_path, state_events in config["events"].items(): + id_to_handlers: dict[str, list[str]] = {} + for handler_name, minified_name in state_events.items(): + if minified_name not in id_to_handlers: + id_to_handlers[minified_name] = [] + id_to_handlers[minified_name].append(handler_name) + + for minified_name, handler_names in id_to_handlers.items(): + if len(handler_names) > 1: + errors.append( + f"Duplicate event_id='{minified_name}' in '{state_path}': {handler_names}" + ) + + # Check for missing states (in code but not in config) + code_state_paths = {get_state_full_path(s) for s in all_states} + missing: list[str] = [ + f"state:{state_path}" + for state_path in code_state_paths + if state_path not in config["states"] + ] + + # Check for missing events (in code but not in config) + for state_cls in all_states: + state_path = get_state_full_path(state_cls) + state_events = config["events"].get(state_path, {}) + missing.extend( + f"event:{state_path}.{handler_name}" + for handler_name in state_cls.event_handlers + if handler_name not in state_events + ) + + # Check for orphaned entries (in config but not in code) + warnings: list[str] = [ + f"Orphaned state in config: {state_path}" + for state_path in config["states"] + if state_path not in code_state_paths + ] + + code_event_keys: dict[str, set[str]] = {} + for state_cls in all_states: + state_path = get_state_full_path(state_cls) + code_event_keys[state_path] = set(state_cls.event_handlers.keys()) + + for state_path, state_events in config["events"].items(): + if state_path not in code_event_keys: + warnings.append(f"Orphaned events for state: {state_path}") + else: + warnings.extend( + f"Orphaned event in config: {state_path}.{handler_name}" + for handler_name in state_events + if handler_name not in code_event_keys[state_path] + ) + + return errors, warnings, missing + + +def sync_minify_config( + existing_config: MinifyConfig, + root_state: type[BaseState], + reassign_deleted: bool = False, + prune: bool = False, +) -> MinifyConfig: + """Synchronize minify configuration with the current state tree. + + Args: + existing_config: The existing configuration to update. + root_state: The root state class. + reassign_deleted: If True, reassign IDs that are no longer in use. + prune: If True, remove entries for states/events that no longer exist. + + Returns: + The updated configuration. + """ + all_states = collect_all_states(root_state) + code_state_paths = {get_state_full_path(s) for s in all_states} + + # Build current event keys by state + code_events_by_state: dict[str, set[str]] = {} + for state_cls in all_states: + state_path = get_state_full_path(state_cls) + code_events_by_state[state_path] = set(state_cls.event_handlers.keys()) + + new_states = dict(existing_config["states"]) + new_events: dict[str, dict[str, str]] = { + k: dict(v) for k, v in existing_config["events"].items() + } + + # Prune orphaned entries if requested + if prune: + new_states = {k: v for k, v in new_states.items() if k in code_state_paths} + new_events = { + state_path: { + h: eid + for h, eid in handlers.items() + if h in code_events_by_state.get(state_path, set()) + } + for state_path, handlers in new_events.items() + if state_path in code_state_paths + } + # Remove empty event dicts + new_events = {k: v for k, v in new_events.items() if v} + + # Find states that need IDs assigned + # Group by parent for sibling-unique assignment + parent_to_children: dict[str | None, list[str]] = {} + for state_cls in all_states: + state_path = get_state_full_path(state_cls) + if state_path not in new_states: + parent = state_cls.get_parent_state() + parent_path = get_state_full_path(parent) if parent else None + if parent_path not in parent_to_children: + parent_to_children[parent_path] = [] + parent_to_children[parent_path].append(state_path) + + # Assign new state IDs + for parent_path, children in parent_to_children.items(): + # Get existing IDs for this parent's children (convert to ints for finding max) + existing_ids: set[int] = set() + for state_path, minified_name in new_states.items(): + parts = state_path.rsplit(".", 1) + sp_parent = parts[0] if len(parts) > 1 else None + # Compare parent paths correctly + if parent_path is None: + if sp_parent is None or "." not in state_path: + existing_ids.add(minified_name_to_int(minified_name)) + elif sp_parent == parent_path: + existing_ids.add(minified_name_to_int(minified_name)) + + # Assign IDs starting from max + 1 (or 0 if reassign_deleted and gaps exist) + next_id = 0 if reassign_deleted else (max(existing_ids, default=-1) + 1) + + for state_path in sorted(children): + while next_id in existing_ids: + next_id += 1 + new_states[state_path] = int_to_minified_name(next_id) + existing_ids.add(next_id) + next_id += 1 + + # Find events that need IDs assigned + for state_cls in all_states: + state_path = get_state_full_path(state_cls) + state_events = new_events.get(state_path, {}) + new_handlers = [h for h in state_cls.event_handlers if h not in state_events] + + if new_handlers: + # Get existing IDs for this state's events + existing_ids = {minified_name_to_int(eid) for eid in state_events.values()} + + next_id = 0 if reassign_deleted else (max(existing_ids, default=-1) + 1) + + for handler_name in sorted(new_handlers): + while next_id in existing_ids: + next_id += 1 + state_events[handler_name] = int_to_minified_name(next_id) + existing_ids.add(next_id) + next_id += 1 + + new_events[state_path] = state_events + + return MinifyConfig( + version=SCHEMA_VERSION, + states=new_states, + events=new_events, + ) diff --git a/reflex/reflex.py b/reflex/reflex.py index b74b44f3fd1..f68865c2def 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -2,7 +2,6 @@ from __future__ import annotations -import operator from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING @@ -843,7 +842,168 @@ def rename(new_name: str): rename_app(new_name, get_config().loglevel) -@cli.command(name="state-tree") +# Minify command group +@cli.group() +def minify(): + """Manage state and event name minification.""" + + +@minify.command(name="init") +@loglevel_option +def minify_init(): + """Initialize minify.json with IDs for all states and events. + + This command scans the codebase and generates a minify.json file + with unique IDs for all states and event handlers. + """ + from reflex.minify import ( + MINIFY_JSON, + _get_minify_json_path, + generate_minify_config, + save_minify_config, + ) + from reflex.state import State + from reflex.utils import prerequisites + + path = _get_minify_json_path() + if path.exists(): + console.error( + f"{MINIFY_JSON} already exists. Use 'reflex minify sync' to update " + "or delete the file to reinitialize." + ) + raise SystemExit(1) + + # Load the user's app to register all state classes + prerequisites.get_app() + + # Generate the configuration + config = generate_minify_config(State) + save_minify_config(config) + + num_states = len(config["states"]) + num_events = len(config["events"]) + console.log( + f"Created {MINIFY_JSON} with {num_states} states and {num_events} events." + ) + + +@minify.command(name="sync") +@loglevel_option +@click.option( + "--reassign-deleted", + is_flag=True, + help="Reassign IDs that are no longer in use (potentially breaking for existing clients).", +) +@click.option( + "--prune", + is_flag=True, + help="Remove entries for states/events that no longer exist in code.", +) +def minify_sync(reassign_deleted: bool, prune: bool): + """Synchronize minify.json with the current codebase. + + Adds new states and events, optionally removes orphaned entries. + """ + from reflex.minify import ( + MINIFY_JSON, + _get_minify_json_path, + _load_minify_config_uncached, + save_minify_config, + sync_minify_config, + ) + from reflex.state import State + from reflex.utils import prerequisites + + path = _get_minify_json_path() + if not path.exists(): + console.error( + f"{MINIFY_JSON} does not exist. Use 'reflex minify init' to create it." + ) + raise SystemExit(1) + + # Load the user's app to register all state classes + prerequisites.get_app() + + # Load existing config + existing_config = _load_minify_config_uncached() + if existing_config is None: + console.error(f"Failed to load {MINIFY_JSON}.") + raise SystemExit(1) + + old_states = len(existing_config["states"]) + old_events = len(existing_config["events"]) + + # Sync the configuration + new_config = sync_minify_config( + existing_config, State, reassign_deleted=reassign_deleted, prune=prune + ) + save_minify_config(new_config) + + new_states = len(new_config["states"]) + new_events = len(new_config["events"]) + + console.log(f"Updated {MINIFY_JSON}:") + console.log(f" States: {old_states} -> {new_states}") + console.log(f" Events: {old_events} -> {new_events}") + + +@minify.command(name="validate") +@loglevel_option +def minify_validate(): + """Validate minify.json against the current codebase. + + Checks for duplicate IDs, missing entries, and orphaned entries. + """ + from reflex.minify import ( + MINIFY_JSON, + _get_minify_json_path, + _load_minify_config_uncached, + validate_minify_config, + ) + from reflex.state import State + from reflex.utils import prerequisites + + path = _get_minify_json_path() + if not path.exists(): + console.error( + f"{MINIFY_JSON} does not exist. Use 'reflex minify init' to create it." + ) + raise SystemExit(1) + + # Load the user's app to register all state classes + prerequisites.get_app() + + # Load existing config + config = _load_minify_config_uncached() + if config is None: + console.error(f"Failed to load {MINIFY_JSON}.") + raise SystemExit(1) + + # Validate + errors, warnings, missing = validate_minify_config(config, State) + + if errors: + console.error("Errors found:") + for error in errors: + console.error(f" - {error}") + + if warnings: + console.warn("Warnings:") + for warning in warnings: + console.warn(f" - {warning}") + + if missing: + console.info("Missing entries (in code but not in config):") + for entry in missing: + console.info(f" - {entry}") + + if not errors and not warnings and not missing: + console.log(f"{MINIFY_JSON} is valid and up-to-date.") + elif errors: + raise SystemExit(1) + + +@minify.command(name="list") @loglevel_option @click.option( "--json", @@ -851,34 +1011,39 @@ def rename(new_name: str): is_flag=True, help="Output as JSON.", ) -def state_tree(output_json: bool): - """Print the state tree with state_id's and event handlers with event_id's.""" +def minify_list(output_json: bool): + """Print the state tree with IDs and minified names.""" from typing import TypedDict - from reflex.event import EVENT_ID_MARKER - from reflex.state import BaseState, State, _int_to_minified_name + from reflex.minify import ( + get_event_id, + get_state_full_path, + get_state_id, + is_minify_enabled, + ) + from reflex.state import BaseState, State from reflex.utils import prerequisites class EventHandlerData(TypedDict): """Type for event handler data in state tree.""" name: str - event_id: int | None - minified_name: str | None + event_id: str | None # The minified name (e.g., "a", "ba") or None class StateTreeData(TypedDict): """Type for state tree data.""" name: str - full_name: str - state_id: int | None - minified_name: str | None + full_path: str + state_id: str | None # The minified name (e.g., "a", "ba") or None event_handlers: list[EventHandlerData] substates: list[StateTreeData] # Load the user's app to register all state classes prerequisites.get_app() + minify_enabled = is_minify_enabled() + def build_state_tree(state_cls: type[BaseState]) -> StateTreeData: """Recursively build state tree data. @@ -888,20 +1053,21 @@ def build_state_tree(state_cls: type[BaseState]) -> StateTreeData: Returns: A dictionary containing the state tree data. """ - state_id = state_cls._state_id + state_path = get_state_full_path(state_cls) + # state_id is now the minified name directly (a string like "a", "ba") + state_id = get_state_id(state_path) if minify_enabled else None # Build event handlers list handlers = [] - for name, handler in state_cls.event_handlers.items(): - event_id = getattr(handler.fn, EVENT_ID_MARKER, None) + for handler_name in sorted(state_cls.event_handlers.keys()): + # event_id is now the minified name directly (a string like "a", "ba") + event_id = ( + get_event_id(state_path, handler_name) if minify_enabled else None + ) handlers.append({ - "name": name, + "name": handler_name, "event_id": event_id, - "minified_name": ( - _int_to_minified_name(event_id) if event_id is not None else None - ), }) - handlers.sort(key=operator.itemgetter("name")) # Build substates recursively substates = [ @@ -911,11 +1077,8 @@ def build_state_tree(state_cls: type[BaseState]) -> StateTreeData: return { "name": state_cls.__name__, - "full_name": state_cls.get_full_name(), + "full_path": state_path, "state_id": state_id, - "minified_name": ( - _int_to_minified_name(state_id) if state_id is not None else None - ), "event_handlers": handlers, "substates": substates, } @@ -930,17 +1093,15 @@ def print_state_tree( prefix: The prefix for indentation. is_last: Whether this is the last item in the current level. """ + # state_id is now the minified name directly (e.g., "a", "ba") state_id = state_data["state_id"] - minified = state_data["minified_name"] # Print the state node connector = "`-- " if is_last else "|-- " if state_id is not None: - console.log( - f'{prefix}{connector}{state_data["name"]} (state_id={state_id} -> "{minified}")' - ) + console.log(f'{prefix}{connector}{state_data["name"]} -> "{state_id}"') else: - console.log(f"{prefix}{connector}{state_data['name']} (state_id=None)") + console.log(f"{prefix}{connector}{state_data['name']}") # Calculate new prefix for children child_prefix = prefix + (" " if is_last else "| ") @@ -956,15 +1117,14 @@ def print_state_tree( for i, handler in enumerate(handlers): is_last_handler = i == len(handlers) - 1 h_connector = "`-- " if is_last_handler else "|-- " + # event_id is now the minified name directly event_id = handler["event_id"] if event_id is not None: console.log( - f'{handler_prefix}{h_connector}{handler["name"]} (event_id={event_id} -> "{handler["minified_name"]}")' + f'{handler_prefix}{h_connector}{handler["name"]} -> "{event_id}"' ) else: - console.log( - f"{handler_prefix}{h_connector}{handler['name']} (event_id=None)" - ) + console.log(f"{handler_prefix}{h_connector}{handler['name']}") # Print substates recursively for i, substate in enumerate(substates): @@ -978,11 +1138,14 @@ def print_state_tree( console.log(json.dumps(tree_data, indent=2)) else: - console.log("State Tree") + if minify_enabled: + console.log("State Tree (minify.json loaded)") + else: + console.log("State Tree (no minify.json)") print_state_tree(tree_data) -@cli.command(name="state-lookup") +@minify.command(name="lookup") @loglevel_option @click.option( "--json", @@ -991,11 +1154,12 @@ def print_state_tree( help="Output detailed info as JSON.", ) @click.argument("minified_path") -def state_lookup(output_json: bool, minified_path: str): +def minify_lookup(output_json: bool, minified_path: str): """Lookup a state by its minified path (e.g., 'a.bU'). Walks the state tree from the root to resolve each segment. """ + from reflex.minify import get_state_full_path, get_state_id from reflex.state import State from reflex.utils import prerequisites @@ -1013,21 +1177,25 @@ def state_lookup(output_json: bool, minified_path: str): parts = minified_path.split(".") result_parts = [] current = State + state_path = get_state_full_path(current) + state_id = get_state_id(state_path) result_parts.append({ "minified": parts[0], - "state_id": current._state_id, + "state_id": state_id, "module": current.__module__, "class": current.__name__, - "full_name": current.get_full_name(), + "full_path": state_path, }) for part in parts[1:]: current = current.get_class_substate(part) + state_path = get_state_full_path(current) + state_id = get_state_id(state_path) result_parts.append({ "minified": part, - "state_id": current._state_id, + "state_id": state_id, "module": current.__module__, "class": current.__name__, - "full_name": current.get_full_name(), + "full_path": state_path, }) if output_json: @@ -1040,90 +1208,6 @@ def state_lookup(output_json: bool, minified_path: str): console.log(f"{info['module']}.{info['class']}") -def _resolve_parent_state(parent: str): - """Resolve a parent argument to a state class. - - Accepts either a state path (minified like 'a.b' or full name) or a class - name (e.g., 'State', 'MySubState'). Tries path resolution first via - get_class_substate, then falls back to searching by class name. - - Args: - parent: Class name or state path identifying the parent state. - - Returns: - The resolved state class. - - Raises: - SystemExit: If the parent cannot be resolved. - """ - from reflex.state import BaseState, State - - # Try as a state path (minified or full name) - try: - return State.get_class_substate(parent) - except ValueError: - pass - - # Fall back to searching by class name - def _find_by_name(cls: type[BaseState], name: str) -> type[BaseState] | None: - if cls.__name__ == name: - return cls - for child in cls.class_subclasses: - result = _find_by_name(child, name) - if result is not None: - return result - return None - - result = _find_by_name(State, parent) - if result is not None: - return result - - console.error(f"No state found matching '{parent}'") - raise SystemExit(1) - - -@cli.command(name="state-next-id") -@loglevel_option -@click.option( - "--after-max", - is_flag=True, - help="Return max(state_id) + 1 instead of first gap.", -) -@click.argument("parent") -def state_next_id(after_max: bool, parent: str): - """Print the next available state_id under PARENT. - - PARENT can be a class name (e.g., 'State', 'MySubState') or a - minified path (e.g., 'a', 'a.b'). Auto-determined from input. - """ - from reflex.utils import prerequisites - - # Load the user's app to register all state classes - prerequisites.get_app() - - parent_cls = _resolve_parent_state(parent) - - # Collect sibling state_ids under the parent - used_ids = { - child._state_id - for child in parent_cls.class_subclasses - if child._state_id is not None - } - - if not used_ids: - console.log("0") - return - - if after_max: - next_id = max(used_ids) + 1 - else: - next_id = 0 - while next_id in used_ids: - next_id += 1 - - console.log(str(next_id)) - - def _convert_reflex_loglevel_to_reflex_cli_loglevel( loglevel: constants.LogLevel, ) -> HostingLogLevel: diff --git a/reflex/state.py b/reflex/state.py index 3bcba70fd9d..95265142727 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -10,7 +10,6 @@ import datetime import functools import inspect -import operator import pickle import re import sys @@ -44,7 +43,6 @@ from reflex.event import ( BACKGROUND_TASK_MARKER, EVENT_ACTIONS_MARKER, - EVENT_ID_MARKER, Event, EventHandler, EventSpec, @@ -106,65 +104,6 @@ VAR_TYPE = TypeVar("VAR_TYPE") -# Characters used for minified names (valid JS identifiers) -MINIFIED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_" - - -def _int_to_minified_name(state_id: int) -> str: - """Convert integer state_id to minified name using base-54 encoding. - - Args: - state_id: The integer state ID to convert. - - Returns: - The minified state name (e.g., 0->'a', 1->'b', 54->'ba'). - - Raises: - ValueError: If state_id is negative. - """ - if state_id < 0: - msg = f"state_id must be non-negative, got {state_id}" - raise ValueError(msg) - - base = len(MINIFIED_NAME_CHARS) - - if state_id == 0: - return MINIFIED_NAME_CHARS[0] - - name = "" - num = state_id - while num > 0: - name = MINIFIED_NAME_CHARS[num % base] + name - num //= base - - return name - - -def _minified_name_to_int(name: str) -> int: - """Convert minified name back to integer state_id. - - Args: - name: The minified state name (e.g., 'a', 'bU'). - - Returns: - The integer state_id. - - Raises: - ValueError: If the name contains invalid characters. - """ - base = len(MINIFIED_NAME_CHARS) - - result = 0 - for char in name: - index = MINIFIED_NAME_CHARS.find(char) - if index == -1: - msg = f"Invalid character '{char}' in minified name" - raise ValueError(msg) - result = result * base + index - - return result - - def _no_chain_background_task(state: BaseState, name: str, fn: Callable) -> Callable: """Protect against directly chaining a background task from another event handler. @@ -453,11 +392,10 @@ class BaseState(EvenMoreBasicBaseState): # Set of states which might need to be recomputed if vars in this state change. _potentially_dirty_states: ClassVar[set[str]] = set() - # The explicit state ID for minification (None = use full name). - _state_id: ClassVar[int | None] = None - # Per-class registry mapping event_id -> event handler name for minification. - _event_id_to_name: ClassVar[builtins.dict[int, str]] = {} + # Populated from minify.json at class creation time. + # Maps minified event ID (e.g., "a") to original handler name (e.g., "increment"). + _event_id_to_name: ClassVar[builtins.dict[str, str]] = {} # The parent state. parent_state: BaseState | None = field(default=None, is_var=False) @@ -576,36 +514,24 @@ def _validate_module_name(cls) -> None: raise NameError(msg) @classmethod - def __init_subclass__( - cls, mixin: bool = False, state_id: int | None = None, **kwargs - ): + def __init_subclass__(cls, mixin: bool = False, **kwargs): """Do some magic for the subclass initialization. Args: mixin: Whether the subclass is a mixin and should not be initialized. - state_id: Explicit state ID for minified state names. **kwargs: The kwargs to pass to the init_subclass method. Raises: - StateValueError: If a substate class shadows another or duplicate state_id. + StateValueError: If a substate class shadows another. """ from reflex.utils.exceptions import StateValueError super().__init_subclass__(**kwargs) - # Mixin states cannot have state_id + # Mixin states are not initialized if cls._mixin: - if state_id is not None: - msg = ( - f"Mixin state '{cls.__module__}.{cls.__name__}' cannot have a state_id. " - "Remove state_id or mixin=True." - ) - raise StateValueError(msg) return - # Store state_id as class variable (only for non-mixins) - cls._state_id = state_id - # Handle locally-defined states for pickling. if "" in cls.__qualname__: cls._handle_local_def() @@ -632,21 +558,6 @@ def __init_subclass__( cls.inherited_vars = parent_state.vars cls.inherited_backend_vars = parent_state.backend_vars - # Check for duplicate state_id among siblings. - if state_id is not None: - for sibling in parent_state.class_subclasses: - if sibling._state_id is not None and sibling._state_id == state_id: - # Allow re-registration of the same class (e.g., module reload) - existing_key = f"{sibling.__module__}.{sibling.__name__}" - new_key = f"{cls.__module__}.{cls.__name__}" - if existing_key != new_key: - msg = ( - f"Duplicate state_id={state_id} among siblings of " - f"'{parent_state.__name__}': already used by " - f"'{sibling.__name__}', cannot be reused by '{cls.__name__}'." - ) - raise StateValueError(msg) - # Check if another substate class with the same name has already been defined. if cls.get_name() in {c.get_name() for c in parent_state.class_subclasses}: # This should not happen, since we have added module prefix to state names in #3214 @@ -740,53 +651,16 @@ def __init_subclass__( cls.event_handlers[name] = handler setattr(cls, name, handler) - # Build event_id registry and validate uniqueness within this state class - cls._event_id_to_name = {} - handlers_with_id: list[tuple[str, Callable[..., Any], int]] = [] - handlers_without_id: list[tuple[str, Callable[..., Any]]] = [] - - for name, fn in events.items(): - event_id = getattr(fn, EVENT_ID_MARKER, None) - if event_id is not None: - if event_id in cls._event_id_to_name: - existing_name = cls._event_id_to_name[event_id] - msg = ( - f"Duplicate event_id={event_id} in state '{cls.__name__}': " - f"handlers '{existing_name}' and '{name}' cannot share the same event_id." - ) - raise StateValueError(msg) - cls._event_id_to_name[event_id] = name - handlers_with_id.append((name, fn, event_id)) - else: - handlers_without_id.append((name, fn)) - - from reflex.environment import EventMinifyMode - - minify_mode = environment.REFLEX_MINIFY_EVENTS.get() - - # In ENFORCE mode, all event handlers must have event_id - if minify_mode == EventMinifyMode.ENFORCE and handlers_without_id: - missing = [name for name, _ in handlers_without_id] - msg = ( - f"State '{cls.__name__}' in ENFORCE mode: event handlers " - f"{missing} are missing required event_id." - ) - raise StateValueError(msg) - - # In BEST_EFFORT mode, auto-assign IDs to handlers without explicit IDs - if minify_mode == EventMinifyMode.BEST_EFFORT and handlers_without_id: - # Find the highest user-defined event_id - max_explicit_id = max((eid for _, _, eid in handlers_with_id), default=-1) - - # Sort handlers without IDs alphabetically by name for deterministic ordering - handlers_without_id.sort(key=operator.itemgetter(0)) + # Build event_id registry from minify.json configuration + from reflex.minify import get_event_id, get_state_full_path, is_minify_enabled - # Assign IDs starting from max_explicit_id + 1 - next_id = max_explicit_id + 1 - for name, fn in handlers_without_id: - setattr(fn, EVENT_ID_MARKER, next_id) - cls._event_id_to_name[next_id] = name - next_id += 1 + cls._event_id_to_name = {} + if is_minify_enabled(): + state_path = get_state_full_path(cls) + for handler_name in events: + event_id = get_event_id(state_path, handler_name) + if event_id is not None: + cls._event_id_to_name[event_id] = handler_name # Initialize per-class var dependency tracking. cls._var_dependencies = {} @@ -830,10 +704,6 @@ def _copy_fn(fn: Callable) -> Callable: newfn.__annotations__ = fn.__annotations__ if mark := getattr(fn, BACKGROUND_TASK_MARKER, None): setattr(newfn, BACKGROUND_TASK_MARKER, mark) - # Preserve event_id for minification - event_id = getattr(fn, EVENT_ID_MARKER, None) - if event_id is not None: - object.__setattr__(newfn, EVENT_ID_MARKER, event_id) # Preserve event_actions from @rx.event decorator if event_actions := getattr(fn, EVENT_ACTIONS_MARKER, None): object.__setattr__(newfn, EVENT_ACTIONS_MARKER, event_actions) @@ -1138,34 +1008,20 @@ def get_name(cls) -> str: """Get the name of the state. Returns: - The name of the state. - - Raises: - StateValueError: If ENFORCE mode is set and state_id is missing. + The name of the state (minified if configured in minify.json). """ - from reflex.environment import StateMinifyMode - from reflex.utils.exceptions import StateValueError + from reflex.minify import get_state_full_path, get_state_id, is_minify_enabled module = cls.__module__.replace(".", "___") full_name = format.to_snake_case(f"{module}___{cls.__name__}") - minify_mode = environment.REFLEX_MINIFY_STATES.get() - - if minify_mode == StateMinifyMode.DISABLED: - return full_name - - if cls._state_id is not None: - return _int_to_minified_name(cls._state_id) - - # state_id not set - if minify_mode == StateMinifyMode.ENFORCE: - msg = ( - f"State '{cls.__module__}.{cls.__name__}' is missing required state_id. " - f"Add state_id parameter: class {cls.__name__}(rx.State, state_id=N)" - ) - raise StateValueError(msg) + # If minification is enabled, look up the state ID from minify.json + if is_minify_enabled(): + state_path = get_state_full_path(cls) + state_id = get_state_id(state_path) + if state_id is not None: + return state_id - # ENABLED mode with no state_id - use full name return full_name @classmethod @@ -1894,11 +1750,8 @@ def _get_original_event_name(cls, minified_name: str) -> str | None: Returns: The original event handler name, or None if not found. """ - # Build reverse lookup: minified_name -> original_name - for event_id, original_name in cls._event_id_to_name.items(): - if _int_to_minified_name(event_id) == minified_name: - return original_name - return None + # Direct lookup: _event_id_to_name maps minified_name -> original_name + return cls._event_id_to_name.get(minified_name) def _get_event_handler( self, event: Event @@ -2669,7 +2522,7 @@ def is_serializable(value: Any) -> bool: T_STATE = TypeVar("T_STATE", bound=BaseState) -class State(BaseState, state_id=0): +class State(BaseState): """The app Base State.""" # The hydrated bool. @@ -2762,7 +2615,7 @@ def wrapper() -> Component: LAST_RELOADED_KEY = "reflex_last_reloaded_on_error" -class FrontendEventExceptionState(State, state_id=0): +class FrontendEventExceptionState(State): """Substate for handling frontend exceptions.""" # If the frontend error message contains any of these strings, automatically reload the page. @@ -2815,7 +2668,7 @@ def handle_frontend_exception( ) -class UpdateVarsInternalState(State, state_id=1): +class UpdateVarsInternalState(State): """Substate for handling internal state var updates.""" async def update_vars_internal(self, vars: dict[str, Any]) -> None: @@ -2839,7 +2692,7 @@ async def update_vars_internal(self, vars: dict[str, Any]) -> None: setattr(var_state, var_name, value) -class OnLoadInternalState(State, state_id=2): +class OnLoadInternalState(State): """Substate for handling on_load event enumeration. This is a separate substate to avoid deserializing the entire state tree for every page navigation. diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 23249f07320..62680716cda 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -446,11 +446,10 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: handler: The event handler to get the parts of. Returns: - The state and function name (possibly minified based on REFLEX_MINIFY_EVENTS). + The state and function name (possibly minified based on minify.json). """ - from reflex.environment import EventMinifyMode, environment - from reflex.event import EVENT_ID_MARKER - from reflex.state import State, _int_to_minified_name + from reflex.minify import is_minify_enabled + from reflex.state import State # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") @@ -468,12 +467,21 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: if state_full_name == FRONTEND_EVENT_STATE and name not in State.__dict__: return ("", to_snake_case(handler.fn.__qualname__)) - # Check for event_id minification - mode = environment.REFLEX_MINIFY_EVENTS.get() - if mode != EventMinifyMode.DISABLED: - event_id = getattr(handler.fn, EVENT_ID_MARKER, None) - if event_id is not None: - name = _int_to_minified_name(event_id) + # Check for event_id minification from minify.json + # The state class stores its event ID mapping in _event_id_to_name + # where key is minified_name and value is original_handler_name + if is_minify_enabled(): + try: + # Get the state class using the path + state_cls = State.get_class_substate(state_full_name) + # Look up the minified name by original handler name + for minified_name, handler_name in state_cls._event_id_to_name.items(): + if handler_name == name: + name = minified_name + break + except ValueError: + # State not found, skip minification + pass return (state_full_name, name) diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py index 1524e1d285a..8a4d057d85e 100644 --- a/tests/integration/test_minification.py +++ b/tests/integration/test_minification.py @@ -2,55 +2,46 @@ from __future__ import annotations -import os +import json from collections.abc import Generator -from functools import partial from typing import TYPE_CHECKING import pytest from selenium.webdriver.common.by import By -from reflex.environment import EventMinifyMode, StateMinifyMode, environment -from reflex.state import _int_to_minified_name +from reflex.minify import MINIFY_JSON, clear_config_cache, int_to_minified_name from reflex.testing import AppHarness if TYPE_CHECKING: from selenium.webdriver.remote.webdriver import WebDriver -def MinificationApp( - root_state_id: int, - sub_state_id: int, - increment_event_id: int | None = None, - update_message_event_id: int | None = None, -): +def MinificationApp(): """Test app for state and event handler minification. - Args: - root_state_id: The state_id for the root state. - sub_state_id: The state_id for the sub state. - increment_event_id: The event_id for the increment event handler. - update_message_event_id: The event_id for the update_message event handler. + This app is used to test that: + 1. Without minify.json, full state/event names are used + 2. With minify.json, minified names are used based on the config """ import reflex as rx from reflex.utils import format - class RootState(rx.State, state_id=root_state_id): - """Root state with explicit state_id.""" + class RootState(rx.State): + """Root state for testing.""" count: int = 0 - @rx.event(event_id=increment_event_id) + @rx.event def increment(self): """Increment the count.""" self.count += 1 - class SubState(RootState, state_id=sub_state_id): - """Sub state with explicit state_id.""" + class SubState(RootState): + """Sub state for testing.""" message: str = "hello" - @rx.event(event_id=update_message_event_id) + @rx.event def update_message(self): """Update the message.""" parent = self.parent_state @@ -103,7 +94,7 @@ def minify_disabled_app( app_harness_env: type[AppHarness], tmp_path_factory: pytest.TempPathFactory, ) -> Generator[AppHarness, None, None]: - """Start app with REFLEX_MINIFY_STATES=disabled. + """Start app WITHOUT minify.json (full names). Args: app_harness_env: AppHarness or AppHarnessProd @@ -112,37 +103,24 @@ def minify_disabled_app( Yields: Running AppHarness instance """ - os.environ["REFLEX_MINIFY_STATES"] = "disabled" - os.environ["REFLEX_MINIFY_EVENTS"] = "disabled" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) + # Clear minify config cache to ensure clean state + clear_config_cache() + # No minify.json file - full names will be used with app_harness_env.create( root=tmp_path_factory.mktemp("minify_disabled"), app_name="minify_disabled", - app_source=partial( - MinificationApp, - root_state_id=3, - sub_state_id=4, - increment_event_id=0, - update_message_event_id=0, - ), + app_source=MinificationApp, ) as harness: yield harness - # Cleanup - os.environ.pop("REFLEX_MINIFY_STATES", None) - os.environ.pop("REFLEX_MINIFY_EVENTS", None) - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) - @pytest.fixture def minify_enabled_app( app_harness_env: type[AppHarness], tmp_path_factory: pytest.TempPathFactory, ) -> Generator[AppHarness, None, None]: - """Start app with minification enabled. + """Start app WITH minify.json (minified names). Args: app_harness_env: AppHarness or AppHarnessProd @@ -151,29 +129,53 @@ def minify_enabled_app( Yields: Running AppHarness instance """ - os.environ["REFLEX_MINIFY_STATES"] = "enabled" - os.environ["REFLEX_MINIFY_EVENTS"] = "enabled" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) + # Clear minify config cache to ensure clean state + clear_config_cache() - with app_harness_env.create( - root=tmp_path_factory.mktemp("minify_enabled"), + app_root = tmp_path_factory.mktemp("minify_enabled") + + # Create the harness object (but don't start yet) + harness = app_harness_env.create( + root=app_root, app_name="minify_enabled", - app_source=partial( - MinificationApp, - root_state_id=10, - sub_state_id=11, - increment_event_id=0, - update_message_event_id=0, - ), - ) as harness: - yield harness + app_source=MinificationApp, + ) - # Cleanup - os.environ.pop("REFLEX_MINIFY_STATES", None) - os.environ.pop("REFLEX_MINIFY_EVENTS", None) - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) + # Create minify.json with explicit IDs for our states and events + # The state paths need to match what get_state_full_path() returns + # Format: module.StateHierarchy (e.g., "minify_enabled.minify_enabled.State.RootState") + # Note: RootState extends rx.State, so the path includes State in the hierarchy + # Version 2 format: string IDs and nested events + app_module = "minify_enabled.minify_enabled" + root_state_path = f"{app_module}.State.RootState" + sub_state_path = f"{app_module}.State.RootState.SubState" + minify_config = { + "version": 1, + "states": { + # Base State needs an ID too since it's in the hierarchy + "reflex.state.State": "a", + # RootState extends State, so path is module.State.RootState + root_state_path: "k", # int_to_minified_name(10) = 'k' + # SubState extends RootState, so path is module.State.RootState.SubState + sub_state_path: "l", # int_to_minified_name(11) = 'l' + }, + "events": { + # Events are now nested under their state path + root_state_path: { + "increment": "f", # int_to_minified_name(5) = 'f' + }, + sub_state_path: { + "update_message": "h", # int_to_minified_name(7) = 'h' + }, + }, + } + + # Write minify.json to the app root directory + minify_path = app_root / MINIFY_JSON + minify_path.write_text(json.dumps(minify_config, indent=2)) + + with harness: + yield harness @pytest.fixture @@ -220,7 +222,7 @@ def test_minification_disabled( minify_disabled_app: AppHarness, driver_disabled: WebDriver, ) -> None: - """Test that DISABLED mode uses full state and event names. + """Test that without minify.json, full state and event names are used. Args: minify_disabled_app: harness for the app @@ -287,7 +289,7 @@ def test_minification_enabled( minify_enabled_app: AppHarness, driver_enabled: WebDriver, ) -> None: - """Test that ENABLED mode uses minified state and event names. + """Test that with minify.json, minified state and event names are used. Args: minify_enabled_app: harness for the app @@ -310,10 +312,11 @@ def test_minification_enabled( root_state_name = root_state_name_el.text sub_state_name = sub_state_name_el.text - # In enabled mode with state_id, names should be minified - # state_id=10 -> 'k', state_id=11 -> 'l' - expected_root_minified = _int_to_minified_name(10) - expected_sub_minified = _int_to_minified_name(11) + # In enabled mode with minify.json, names should be minified + # RootState has state_id=10 -> 'k' + # SubState has state_id=11 -> 'l' + expected_root_minified = int_to_minified_name(10) + expected_sub_minified = int_to_minified_name(11) assert expected_root_minified in root_state_name assert expected_sub_minified in sub_state_name @@ -337,19 +340,22 @@ def test_minification_enabled( else update_handler_text ) - # In enabled mode with event_id, names should be minified - # event_id=0 -> 'a' for both handlers - expected_event_minified = _int_to_minified_name(0) + # In enabled mode with minify.json: + # - increment has event_id=5 -> 'f' + # - update_message has event_id=7 -> 'h' + expected_increment_minified = int_to_minified_name(5) + expected_update_minified = int_to_minified_name(7) # Event handler format: "state_name.event_name" - # For increment: "k.a" (state_id=10 -> 'k', event_id=0 -> 'a') - # For update_message: "k.l.a" (state_id=10.11 -> 'k.l', event_id=0 -> 'a') - # The event name should be minified to 'a' - assert increment_handler.endswith(f".{expected_event_minified}"), ( - f"Expected minified event name, got: {increment_handler}" + # For increment: "k.f" (state_id=10 -> 'k', event_id=5 -> 'f') + # For update_message: "k.l.h" (state_id=10.11 -> 'k.l', event_id=7 -> 'h') + assert increment_handler.endswith(f".{expected_increment_minified}"), ( + f"Expected minified event name ending with '.{expected_increment_minified}', " + f"got: {increment_handler}" ) - assert update_handler.endswith(f".{expected_event_minified}"), ( - f"Expected minified event name, got: {update_handler}" + assert update_handler.endswith(f".{expected_update_minified}"), ( + f"Expected minified event name ending with '.{expected_update_minified}', " + f"got: {update_handler}" ) # The handler names should NOT contain the original method names diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index b59343ac693..eec1beea942 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -1,371 +1,356 @@ -"""Unit tests for state and event handler minification.""" +"""Unit tests for state and event handler minification via minify.json.""" from __future__ import annotations +import json + import pytest -from reflex.environment import EventMinifyMode, StateMinifyMode, environment -from reflex.event import EVENT_ID_MARKER -from reflex.state import ( - BaseState, - FrontendEventExceptionState, - OnLoadInternalState, - State, - UpdateVarsInternalState, - _int_to_minified_name, - _minified_name_to_int, +from reflex.minify import ( + MINIFY_JSON, + SCHEMA_VERSION, + MinifyConfig, + clear_config_cache, + generate_minify_config, + get_event_id, + get_state_full_path, + get_state_id, + int_to_minified_name, + is_minify_enabled, + minified_name_to_int, + save_minify_config, + sync_minify_config, + validate_minify_config, ) -from reflex.utils.exceptions import StateValueError - - -@pytest.fixture -def reset_minify_mode(): - """Reset minify modes to DISABLED after each test.""" - original_states = environment.REFLEX_MINIFY_STATES.get() - original_events = environment.REFLEX_MINIFY_EVENTS.get() - yield - environment.REFLEX_MINIFY_STATES.set(original_states) - environment.REFLEX_MINIFY_EVENTS.set(original_events) +from reflex.state import BaseState, State class TestIntToMinifiedName: - """Tests for _int_to_minified_name function.""" + """Tests for int_to_minified_name function.""" def test_zero(self): """Test that 0 maps to 'a'.""" - assert _int_to_minified_name(0) == "a" + assert int_to_minified_name(0) == "a" def test_single_char(self): """Test single character mappings.""" - assert _int_to_minified_name(1) == "b" - assert _int_to_minified_name(25) == "z" - assert _int_to_minified_name(26) == "A" - assert _int_to_minified_name(51) == "Z" - assert _int_to_minified_name(52) == "$" - assert _int_to_minified_name(53) == "_" + assert int_to_minified_name(1) == "b" + assert int_to_minified_name(25) == "z" + assert int_to_minified_name(26) == "A" + assert int_to_minified_name(51) == "Z" + assert int_to_minified_name(52) == "$" + assert int_to_minified_name(53) == "_" def test_two_chars(self): """Test two character mappings (base 54).""" # 54 = 1*54 + 0 -> 'ba' - assert _int_to_minified_name(54) == "ba" + assert int_to_minified_name(54) == "ba" # 55 = 1*54 + 1 -> 'bb' - assert _int_to_minified_name(55) == "bb" + assert int_to_minified_name(55) == "bb" def test_unique_names(self): """Test that a large range of IDs produce unique names.""" names = set() for i in range(10000): - name = _int_to_minified_name(i) + name = int_to_minified_name(i) assert name not in names, f"Duplicate name {name} for id {i}" names.add(name) + def test_negative_raises(self): + """Test that negative IDs raise ValueError.""" + with pytest.raises(ValueError, match="non-negative"): + int_to_minified_name(-1) -class TestStateIdValidation: - """Tests for state_id validation in __init_subclass__.""" - def test_state_with_explicit_id(self): - """Test that a state can be created with an explicit state_id.""" +class TestMinifiedNameToInt: + """Tests for minified_name_to_int reverse conversion.""" - class TestState(BaseState, state_id=100): - pass + def test_single_char(self): + """Test single character conversion.""" + assert minified_name_to_int("a") == 0 + assert minified_name_to_int("b") == 1 + assert minified_name_to_int("z") == 25 + assert minified_name_to_int("A") == 26 + assert minified_name_to_int("Z") == 51 - assert TestState._state_id == 100 + def test_roundtrip(self): + """Test that int -> minified -> int roundtrip works.""" + for i in range(1000): + minified = int_to_minified_name(i) + result = minified_name_to_int(minified) + assert result == i, f"Roundtrip failed for {i}: {minified} -> {result}" - def test_state_without_id(self): - """Test that a state can be created without state_id.""" + def test_invalid_char_raises(self): + """Test that invalid characters raise ValueError.""" + with pytest.raises(ValueError, match="Invalid character"): + minified_name_to_int("!") - class TestState(BaseState): - pass - assert TestState._state_id is None +class TestGetStateFullPath: + """Tests for get_state_full_path function.""" - def test_duplicate_state_id_among_siblings_raises(self): - """Test that duplicate state_id among siblings raises StateValueError.""" + def test_root_state_path(self): + """Test that root State has correct full path.""" + path = get_state_full_path(State) + assert path == "reflex.state.State" - class ParentState(BaseState, state_id=200): - pass + def test_substate_path(self): + """Test that substates have correct full paths.""" - class FirstChild(ParentState, state_id=10): + class TestState(BaseState): pass - with pytest.raises(StateValueError, match="Duplicate state_id=10"): + path = get_state_full_path(TestState) + assert "TestState" in path + assert path.startswith("tests.units.test_minification.") - class SecondChild(ParentState, state_id=10): - pass - def test_same_state_id_across_branches_allowed(self): - """Test that the same state_id can be used in different branches.""" +@pytest.fixture +def temp_minify_json(tmp_path, monkeypatch): + """Create a temporary directory and mock cwd to use it for minify.json. + + Yields: + The temporary directory path. + """ + monkeypatch.chdir(tmp_path) + clear_config_cache() + # Clear State caches to ensure clean slate + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + yield tmp_path + # Clean up: clear config and all cached state names + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + + +class TestMinifyConfig: + """Tests for minify.json configuration loading and saving.""" + + def test_no_config_returns_none(self, temp_minify_json): + """Test that missing minify.json returns None.""" + assert is_minify_enabled() is False + assert get_state_id("any.path") is None + assert get_event_id("any.path", "handler") is None + + def test_save_and_load_config(self, temp_minify_json): + """Test saving and loading a config.""" + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) - class Root(BaseState, state_id=210): - pass + # Clear cache and reload + clear_config_cache() - class BranchA(Root, state_id=1): - pass + assert is_minify_enabled() is True + assert get_state_id("test.module.MyState") == "a" + assert get_event_id("test.module.MyState", "handler") == "a" - class BranchB(Root, state_id=2): - pass + def test_invalid_version_raises(self, temp_minify_json): + """Test that invalid version raises ValueError.""" + config = {"version": 999, "states": {}, "events": {}} + path = temp_minify_json / MINIFY_JSON + with path.open("w") as f: + json.dump(config, f) - class LeafA(BranchA, state_id=5): - pass + clear_config_cache() - class LeafB(BranchB, state_id=5): # same state_id=5, different parent -- OK! - pass + with pytest.raises(ValueError, match=r"Unsupported.*version"): + is_minify_enabled() - # Both should succeed - state_id is per-parent (sibling uniqueness) - assert LeafA._state_id == 5 - assert LeafB._state_id == 5 - # But they have different full names - assert LeafA.get_parent_state() is BranchA - assert LeafB.get_parent_state() is BranchB + def test_missing_states_raises(self, temp_minify_json): + """Test that missing 'states' key raises ValueError.""" + config = {"version": SCHEMA_VERSION, "events": {}} + path = temp_minify_json / MINIFY_JSON + with path.open("w") as f: + json.dump(config, f) + clear_config_cache() -class TestGetNameMinification: - """Tests for get_name with minification modes.""" + with pytest.raises(ValueError, match="'states' must be"): + is_minify_enabled() - def test_disabled_mode_uses_full_name(self, reset_minify_mode): - """Test DISABLED mode always uses full name even with state_id.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - class TestState(BaseState, state_id=300): - pass +class TestGenerateMinifyConfig: + """Tests for generate_minify_config function.""" - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() + def test_generate_for_root_state(self): + """Test generating config for the root State.""" + config = generate_minify_config(State) - name = TestState.get_name() - # Should be full name, not minified - assert "test_state" in name.lower() - assert name != _int_to_minified_name(300) + assert config["version"] == SCHEMA_VERSION + assert "reflex.state.State" in config["states"] + # State should have event handlers like set_is_hydrated + state_path = "reflex.state.State" + assert state_path in config["events"] + assert "set_is_hydrated" in config["events"][state_path] - def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): - """Test ENABLED mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) + def test_generates_unique_sibling_ids(self): + """Test that sibling states get unique IDs.""" - class TestState(BaseState, state_id=301): + class ParentState(BaseState): pass - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - assert name == _int_to_minified_name(301) - - def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): - """Test ENABLED mode without state_id uses full name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) - - class TestState(BaseState): + class ChildA(ParentState): pass - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() - - name = TestState.get_name() - # Should contain the class name - assert "test_state" in name.lower() - - def test_enforce_mode_without_id_raises(self, reset_minify_mode): - """Test ENFORCE mode without state_id raises error during class definition.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - - # Error is raised during class definition because get_name() is called - # during __init_subclass__ - with pytest.raises(StateValueError, match="missing required state_id"): - - class TestState(BaseState): - pass - - def test_enforce_mode_with_id_uses_minified(self, reset_minify_mode): - """Test ENFORCE mode with state_id uses minified name.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - - class TestState(BaseState, state_id=302): + class ChildB(ParentState): pass - # Clear the lru_cache to get fresh result - TestState.get_name.cache_clear() + config = generate_minify_config(ParentState) - name = TestState.get_name() - assert name == _int_to_minified_name(302) + # Find the IDs for ChildA and ChildB + child_a_path = get_state_full_path(ChildA) + child_b_path = get_state_full_path(ChildB) + child_a_id = config["states"].get(child_a_path) + child_b_id = config["states"].get(child_b_path) -class TestMixinState: - """Tests for mixin states.""" + assert child_a_id is not None + assert child_b_id is not None + assert child_a_id != child_b_id - def test_mixin_no_state_id_required(self, reset_minify_mode): - """Test that mixin states don't require state_id even in ENFORCE mode.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENFORCE) - class MixinState(BaseState, mixin=True): - pass +class TestValidateMinifyConfig: + """Tests for validate_minify_config function.""" - # Mixin states should not raise even without state_id - assert MixinState._state_id is None - # Mixin states have _mixin = True set, so get_name isn't typically called - # but the class should be created without error + def test_valid_config_no_errors(self): + """Test that a valid config produces no errors.""" + config = generate_minify_config(State) + errors, _warnings, missing = validate_minify_config(config, State) - def test_mixin_with_state_id_raises(self): - """Test that mixin states cannot have state_id.""" - with pytest.raises(StateValueError, match="cannot have a state_id"): + assert len(errors) == 0 + assert len(missing) == 0 - class MixinWithId(BaseState, mixin=True, state_id=999): - pass + def test_duplicate_state_ids_detected(self): + """Test that duplicate state IDs are detected.""" + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "test.Parent": "a", + "test.Parent.ChildA": "b", + "test.Parent.ChildB": "b", # Duplicate! + }, + "events": {}, + } + # Create a mock state tree + class Parent(BaseState): + pass -class TestEventIdValidation: - """Tests for event_id validation in __init_subclass__.""" + errors, _warnings, _missing = validate_minify_config(config, Parent) - def test_event_with_explicit_id(self): - """Test that an event handler can be created with an explicit event_id.""" - import reflex as rx + assert any("Duplicate state_id='b'" in e for e in errors) - class TestState(BaseState, state_id=400): - @rx.event(event_id=0) - def my_handler(self): - pass - assert 0 in TestState._event_id_to_name - assert TestState._event_id_to_name[0] == "my_handler" +class TestSyncMinifyConfig: + """Tests for sync_minify_config function.""" - def test_event_without_id(self): - """Test that an event handler can be created without event_id.""" - import reflex as rx + def test_sync_adds_new_states(self): + """Test that sync adds new states.""" - class TestState(BaseState, state_id=401): - @rx.event - def my_handler(self): + class TestState(BaseState): + def handler(self): pass - # Should not be in the registry - assert 0 not in TestState._event_id_to_name - - def test_duplicate_event_id_within_state_raises(self): - """Test that duplicate event_id within same state raises StateValueError.""" - import reflex as rx - - with pytest.raises(StateValueError, match="Duplicate event_id=0"): + # Start with empty config + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": {}, + } - class TestState(BaseState, state_id=402): - @rx.event(event_id=0) - def handler1(self): - pass + new_config = sync_minify_config(existing_config, TestState) - @rx.event(event_id=0) - def handler2(self): - pass + # Should have added the state + state_path = get_state_full_path(TestState) + assert state_path in new_config["states"] + assert state_path in new_config["events"] + assert "handler" in new_config["events"][state_path] - def test_same_event_id_across_states_allowed(self): - """Test that same event_id can be used in different state classes.""" - import reflex as rx + def test_sync_preserves_existing_ids(self): + """Test that sync preserves existing IDs.""" - class StateA(BaseState, state_id=403): - @rx.event(event_id=0) - def handler(self): + class TestState(BaseState): + def handler_a(self): pass - class StateB(BaseState, state_id=404): - @rx.event(event_id=0) - def handler(self): + def handler_b(self): pass - # Both should succeed - event_id is per-state - assert StateA._event_id_to_name[0] == "handler" - assert StateB._event_id_to_name[0] == "handler" - - def test_event_id_stored_on_function(self): - """Test that event_id is stored as EVENT_ID_MARKER on the function.""" - import reflex as rx - - @rx.event(event_id=42) - def standalone_handler(self): - pass - - assert hasattr(standalone_handler, EVENT_ID_MARKER) - assert getattr(standalone_handler, EVENT_ID_MARKER) == 42 - - -class TestEventHandlerMinification: - """Tests for event handler name minification in get_event_handler_parts.""" - - def test_disabled_mode_uses_full_name(self, reset_minify_mode): - """Test DISABLED mode uses full event name even with event_id.""" - import reflex as rx - from reflex.utils.format import get_event_handler_parts + state_path = get_state_full_path(TestState) - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.DISABLED) + # Start with partial config (using string IDs in v2 format) + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {state_path: "bU"}, # Some arbitrary minified name + "events": {state_path: {"handler_a": "k"}}, # Another arbitrary name + } - class TestState(BaseState, state_id=500): - @rx.event(event_id=0) - def my_handler(self): - pass + new_config = sync_minify_config(existing_config, TestState) - handler = TestState.event_handlers["my_handler"] - _, event_name = get_event_handler_parts(handler) + # Existing IDs should be preserved + assert new_config["states"][state_path] == "bU" + assert new_config["events"][state_path]["handler_a"] == "k" + # New handler should be added with next ID (k=10, so next is l=11) + assert "handler_b" in new_config["events"][state_path] + assert ( + new_config["events"][state_path]["handler_b"] == "l" + ) # 10 + 1 = 11 -> 'l' - # Should use full name, not minified - assert event_name == "my_handler" - def test_enabled_mode_with_id_uses_minified(self, reset_minify_mode): - """Test ENABLED mode with event_id uses minified name.""" - import reflex as rx - from reflex.utils.format import get_event_handler_parts +class TestStateMinification: + """Tests for state name minification with minify.json.""" - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) + def test_state_uses_full_name_without_config(self, temp_minify_json): + """Test that states use full names when no minify.json exists.""" - class TestState(BaseState, state_id=501): - @rx.event(event_id=5) - def my_handler(self): - pass + class TestState(BaseState): + pass TestState.get_name.cache_clear() - handler = TestState.event_handlers["my_handler"] - _, event_name = get_event_handler_parts(handler) - - # Should use minified name - assert event_name == _int_to_minified_name(5) - assert event_name == "f" + name = TestState.get_name() - def test_enabled_mode_without_id_uses_full_name(self, reset_minify_mode): - """Test ENABLED mode without event_id uses full name.""" - import reflex as rx - from reflex.utils.format import get_event_handler_parts + # Should be the full name (snake_case module___class) + assert "test_state" in name.lower() - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) + def test_state_uses_minified_name_with_config(self, temp_minify_json): + """Test that states use minified names when minify.json exists.""" - class TestState(BaseState, state_id=502): - @rx.event - def my_handler(self): - pass + class TestState(BaseState): + pass + state_path = get_state_full_path(TestState) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {state_path: "f"}, # Direct minified name + "events": {}, + } + save_minify_config(config) + clear_config_cache() TestState.get_name.cache_clear() - handler = TestState.event_handlers["my_handler"] - _, event_name = get_event_handler_parts(handler) - # Should use full name - assert event_name == "my_handler" - - def test_enforce_mode_without_event_id_raises(self, reset_minify_mode): - """Test ENFORCE mode without event_id raises error during class definition.""" - import reflex as rx + name = TestState.get_name() - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENFORCE) + # Should be the minified name directly + assert name == "f" - with pytest.raises(StateValueError, match="missing required event_id"): - class TestState(BaseState, state_id=503): - @rx.event - def my_handler(self): - pass +class TestEventMinification: + """Tests for event handler name minification with minify.json.""" - def test_enforce_mode_with_event_id_works(self, reset_minify_mode): - """Test ENFORCE mode with event_id creates state successfully.""" + def test_event_uses_full_name_without_config(self, temp_minify_json): + """Test that event handlers use full names when no minify.json exists.""" import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENFORCE) - - class TestState(BaseState, state_id=504): - @rx.event(event_id=0) + class TestState(BaseState): + @rx.event def my_handler(self): pass @@ -373,368 +358,64 @@ def my_handler(self): handler = TestState.event_handlers["my_handler"] _, event_name = get_event_handler_parts(handler) - # Should use minified name - assert event_name == _int_to_minified_name(0) - assert event_name == "a" - - -class TestMixinEventHandlers: - """Tests for event handlers from mixin states.""" + # Should use full name + assert event_name == "my_handler" - def test_mixin_event_id_preserved(self, reset_minify_mode): - """Test that event_id from mixin handlers is preserved when inherited.""" + def test_event_uses_minified_name_with_config(self, temp_minify_json): + """Test that event handlers use minified names when minify.json exists.""" import reflex as rx from reflex.utils.format import get_event_handler_parts - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) - - class MixinState(BaseState, mixin=True): - @rx.event(event_id=10) - def mixin_handler(self): - pass - - # Need to inherit from both mixin AND a non-mixin base (BaseState) - # to create a non-mixin concrete state - class ConcreteState(MixinState, BaseState, state_id=600): - @rx.event(event_id=0) - def own_handler(self): - pass - - ConcreteState.get_name.cache_clear() - - # Both handlers should have their event_ids preserved - assert 10 in ConcreteState._event_id_to_name - assert ConcreteState._event_id_to_name[10] == "mixin_handler" - assert 0 in ConcreteState._event_id_to_name - assert ConcreteState._event_id_to_name[0] == "own_handler" - - # Check minified names - mixin_handler = ConcreteState.event_handlers["mixin_handler"] - own_handler = ConcreteState.event_handlers["own_handler"] - - _, mixin_name = get_event_handler_parts(mixin_handler) - _, own_name = get_event_handler_parts(own_handler) - - assert mixin_name == _int_to_minified_name(10) # "k" - assert own_name == _int_to_minified_name(0) # "a" - - def test_mixin_event_id_conflict_raises(self, reset_minify_mode): - """Test that conflicting event_ids from mixin and concrete state raises error.""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.ENABLED) - - class MixinState(BaseState, mixin=True): - @rx.event(event_id=0) - def mixin_handler(self): - pass - - with pytest.raises(StateValueError, match="Duplicate event_id=0"): - # Need to inherit from both mixin AND a non-mixin base (BaseState) - class ConcreteState(MixinState, BaseState, state_id=601): - @rx.event(event_id=0) - def own_handler(self): - pass - - -class TestMinifiedNameToInt: - """Tests for _minified_name_to_int reverse conversion.""" - - def test_single_char(self): - """Test single character conversion.""" - assert _minified_name_to_int("a") == 0 - assert _minified_name_to_int("b") == 1 - assert _minified_name_to_int("z") == 25 - assert _minified_name_to_int("A") == 26 - assert _minified_name_to_int("Z") == 51 - - def test_roundtrip(self): - """Test that int -> minified -> int roundtrip works.""" - for i in range(1000): - minified = _int_to_minified_name(i) - result = _minified_name_to_int(minified) - assert result == i, f"Roundtrip failed for {i}: {minified} -> {result}" - - def test_invalid_char_raises(self): - """Test that invalid characters raise ValueError.""" - with pytest.raises(ValueError, match="Invalid character"): - _minified_name_to_int("!") - - def test_state_has_state_id_zero(self): - """Test that the root State class has state_id=0.""" - assert State._state_id == 0 - assert State.__module__ == "reflex.state" - assert State.__name__ == "State" - - def test_next_sibling_state_id(self): - """Test finding next available state_id among siblings.""" - - class Parent(BaseState, state_id=700): - pass - - class Child0(Parent, state_id=0): - pass - - class Child1(Parent, state_id=1): - pass - - # Find first gap starting from 0 among Parent's children - used_ids = { - child._state_id - for child in Parent.class_subclasses - if child._state_id is not None + # First, set up the config BEFORE creating the state class + # The event_id_to_name registry is built during __init_subclass__ + # so the config must exist before the class is defined + + # For this test, we extend State (not BaseState) so that + # get_event_handler_parts can look up our state in the State tree. + # We need to include State's full path in our config too. + + # The state path includes the full class hierarchy from State. + # For a direct subclass of State defined in this test module, + # get_state_full_path returns: "tests.units.test_minification.State.TestStateWithMinifiedEvent" + # (module + class hierarchy from root state to leaf) + + expected_module = "tests.units.test_minification" + expected_state_path = f"{expected_module}.State.TestStateWithMinifiedEvent" + + # Also need to include the base State in the config (v2 format with nested events) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", # Base State + expected_state_path: "b", # Our test state + }, + "events": { + expected_state_path: {"my_handler": "d"}, # Nested under state path + }, } - next_id = 0 - while next_id in used_ids: - next_id += 1 - - assert next_id == 2 - - -class TestInternalStateIds: - """Tests for internal state classes having correct state_id values.""" - - def test_state_has_id_0(self): - """Test that the base State class has state_id=0.""" - assert State._state_id == 0 - - def test_frontend_exception_state_has_id_0(self): - """Test that FrontendEventExceptionState has state_id=0.""" - assert FrontendEventExceptionState._state_id == 0 - - def test_update_vars_internal_state_has_id_1(self): - """Test that UpdateVarsInternalState has state_id=1.""" - assert UpdateVarsInternalState._state_id == 1 - - def test_on_load_internal_state_has_id_2(self): - """Test that OnLoadInternalState has state_id=2.""" - assert OnLoadInternalState._state_id == 2 - - def test_internal_states_minified_names(self, reset_minify_mode): - """Test that internal states get correct minified names when enabled.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.ENABLED) - - # Clear the lru_cache to get fresh results - State.get_name.cache_clear() - FrontendEventExceptionState.get_name.cache_clear() - UpdateVarsInternalState.get_name.cache_clear() - OnLoadInternalState.get_name.cache_clear() - - # State (id=0) -> "a" - assert State.get_name() == "a" - # FrontendEventExceptionState (id=0) -> "a" - assert FrontendEventExceptionState.get_name() == "a" - # UpdateVarsInternalState (id=1) -> "b" - assert UpdateVarsInternalState.get_name() == "b" - # OnLoadInternalState (id=2) -> "c" - assert OnLoadInternalState.get_name() == "c" - - def test_internal_states_full_names_when_disabled(self, reset_minify_mode): - """Test that internal states use full names when minification is disabled.""" - environment.REFLEX_MINIFY_STATES.set(StateMinifyMode.DISABLED) - - # Clear the lru_cache to get fresh results + save_minify_config(config) + clear_config_cache() State.get_name.cache_clear() - FrontendEventExceptionState.get_name.cache_clear() - UpdateVarsInternalState.get_name.cache_clear() - OnLoadInternalState.get_name.cache_clear() - - # Should contain the class name pattern - assert "state" in State.get_name().lower() - assert "frontend" in FrontendEventExceptionState.get_name().lower() - assert "update" in UpdateVarsInternalState.get_name().lower() - assert "on_load" in OnLoadInternalState.get_name().lower() - - -class TestBestEffortMode: - """Tests for BEST_EFFORT event minification mode.""" - - def test_best_effort_assigns_ids_to_handlers_without_explicit_id( - self, reset_minify_mode - ): - """Test that BEST_EFFORT mode assigns event_ids to handlers without explicit IDs.""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=700): - @rx.event - def handler_a(self): - pass - - @rx.event - def handler_b(self): - pass - - # Both handlers should have event_ids assigned - assert 0 in TestState._event_id_to_name - assert 1 in TestState._event_id_to_name - # Should be assigned alphabetically - assert TestState._event_id_to_name[0] == "handler_a" - assert TestState._event_id_to_name[1] == "handler_b" - - def test_best_effort_starts_after_highest_explicit_id(self, reset_minify_mode): - """Test that BEST_EFFORT mode starts assigning IDs after the highest explicit ID.""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=701): - @rx.event(event_id=5) - def explicit_handler(self): - pass - - @rx.event(event_id=10) - def another_explicit(self): - pass + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + # Now create the state class extending State - it will pick up the config + class TestStateWithMinifiedEvent(State): @rx.event - def auto_handler(self): - pass - - # Explicit handlers should keep their IDs - assert TestState._event_id_to_name[5] == "explicit_handler" - assert TestState._event_id_to_name[10] == "another_explicit" - # Auto-assigned handler should start at max_explicit_id + 1 = 11 - assert TestState._event_id_to_name[11] == "auto_handler" - - def test_best_effort_assigns_ids_alphabetically(self, reset_minify_mode): - """Test that BEST_EFFORT mode assigns IDs to handlers alphabetically by name.""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=702): - @rx.event - def zebra_handler(self): - pass - - @rx.event - def alpha_handler(self): - pass - - @rx.event - def middle_handler(self): - pass - - # Should be assigned alphabetically: alpha, middle, zebra - assert TestState._event_id_to_name[0] == "alpha_handler" - assert TestState._event_id_to_name[1] == "middle_handler" - assert TestState._event_id_to_name[2] == "zebra_handler" - - def test_best_effort_with_no_explicit_ids(self, reset_minify_mode): - """Test that BEST_EFFORT mode works with no explicit IDs (starts at 0).""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=703): - @rx.event - def first_handler(self): - pass - - @rx.event - def second_handler(self): - pass - - # Should start at 0 since no explicit IDs - assert TestState._event_id_to_name[0] == "first_handler" - assert TestState._event_id_to_name[1] == "second_handler" - - def test_best_effort_minifies_all_handlers(self, reset_minify_mode): - """Test that BEST_EFFORT mode minifies all handlers in format output.""" - import reflex as rx - from reflex.utils.format import get_event_handler_parts - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=704): - @rx.event(event_id=0) - def explicit_handler(self): - pass - - @rx.event - def auto_handler(self): - pass - - TestState.get_name.cache_clear() - - explicit = TestState.event_handlers["explicit_handler"] - auto = TestState.event_handlers["auto_handler"] - - _, explicit_name = get_event_handler_parts(explicit) - _, auto_name = get_event_handler_parts(auto) - - # Both should be minified - assert explicit_name == _int_to_minified_name(0) # 'a' - assert auto_name == _int_to_minified_name(1) # 'b' - - def test_best_effort_skips_gaps_in_explicit_ids(self, reset_minify_mode): - """Test that BEST_EFFORT mode skips gaps in explicit IDs (doesn't fill them).""" - import reflex as rx - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=705): - @rx.event(event_id=0) - def handler_zero(self): - pass - - @rx.event(event_id=5) - def handler_five(self): - pass - - @rx.event(event_id=10) - def handler_ten(self): - pass - - @rx.event - def auto_handler(self): - pass - - # Explicit handlers keep their IDs - assert TestState._event_id_to_name[0] == "handler_zero" - assert TestState._event_id_to_name[5] == "handler_five" - assert TestState._event_id_to_name[10] == "handler_ten" - # Auto-assigned starts at 11, not filling gaps at 1-4 or 6-9 - assert TestState._event_id_to_name[11] == "auto_handler" - # Verify gaps are not filled - assert 1 not in TestState._event_id_to_name - assert 6 not in TestState._event_id_to_name - - def test_best_effort_mixed_explicit_and_auto(self, reset_minify_mode): - """Test BEST_EFFORT with a mix of explicit and auto-assigned handlers.""" - import reflex as rx - from reflex.utils.format import get_event_handler_parts - - environment.REFLEX_MINIFY_EVENTS.set(EventMinifyMode.BEST_EFFORT) - - class TestState(BaseState, state_id=706): - @rx.event(event_id=3) - def explicit_three(self): + def my_handler(self): pass - @rx.event - def auto_alpha(self): - pass + # Verify the path matches what we expected + actual_path = get_state_full_path(TestStateWithMinifiedEvent) + assert actual_path == expected_state_path, ( + f"Expected path {expected_state_path}, got {actual_path}" + ) - @rx.event - def auto_beta(self): - pass + # The state's _event_id_to_name should be populated (key is minified name) + assert TestStateWithMinifiedEvent._event_id_to_name == {"d": "my_handler"} - TestState.get_name.cache_clear() + handler = TestStateWithMinifiedEvent.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) - # Check registry - assert TestState._event_id_to_name[3] == "explicit_three" - assert TestState._event_id_to_name[4] == "auto_alpha" # alphabetically first - assert TestState._event_id_to_name[5] == "auto_beta" # alphabetically second - - # Check that all handlers are minified correctly - for name, expected_id in [ - ("explicit_three", 3), - ("auto_alpha", 4), - ("auto_beta", 5), - ]: - handler = TestState.event_handlers[name] - _, minified = get_event_handler_parts(handler) - assert minified == _int_to_minified_name(expected_id) + # Should be the minified name directly + assert event_name == "d" From ee4c7f172652d90f453147f9f739f1744e91ddcf Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 13:51:20 +0100 Subject: [PATCH 14/28] fix codespell --- tests/units/test_minification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index eec1beea942..41dbede7167 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -288,14 +288,14 @@ def handler_b(self): # Start with partial config (using string IDs in v2 format) existing_config: MinifyConfig = { "version": SCHEMA_VERSION, - "states": {state_path: "bU"}, # Some arbitrary minified name + "states": {state_path: "bU"}, # codespell:ignore "events": {state_path: {"handler_a": "k"}}, # Another arbitrary name } new_config = sync_minify_config(existing_config, TestState) # Existing IDs should be preserved - assert new_config["states"][state_path] == "bU" + assert new_config["states"][state_path] == "bU" # codespell:ignore assert new_config["events"][state_path]["handler_a"] == "k" # New handler should be added with next ID (k=10, so next is l=11) assert "handler_b" in new_config["events"][state_path] From bcc4b499485a0d74de7660085c952b88fc6b373d Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 23:06:36 +0100 Subject: [PATCH 15/28] add support for dynamic event handlers, streamline event handler formatting --- reflex/compiler/templates.py | 12 ++-- reflex/constants/event.py | 3 + reflex/state.py | 48 +++++++++---- reflex/utils/format.py | 13 +++- tests/units/test_minification.py | 113 +++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 20 deletions(-) diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 7eccca3e6ea..5facfd73cf8 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -9,7 +9,7 @@ from reflex import constants from reflex.constants import Hooks from reflex.constants.state import CAMEL_CASE_MEMO_MARKER -from reflex.utils.format import format_state_name, json_dumps +from reflex.utils.format import format_event_handler, format_state_name, json_dumps from reflex.vars.base import VarData if TYPE_CHECKING: @@ -284,8 +284,10 @@ def context_template( # Compute dynamic state names that respect minification settings main_state_name = State.get_name() - on_load_internal = f"{OnLoadInternalState.get_name()}.on_load_internal" - update_vars_internal = f"{UpdateVarsInternalState.get_name()}.update_vars_internal" + on_load_internal = format_event_handler(OnLoadInternalState.on_load_internal) + update_vars_internal = format_event_handler( + UpdateVarsInternalState.update_vars_internal + ) exception_state_full = FrontendEventExceptionState.get_full_name() initial_state = initial_state or {} @@ -314,7 +316,7 @@ def context_template( if (client_storage_vars && Object.keys(client_storage_vars).length !== 0) {{ internal_events.push( ReflexEvent( - '{state_name}.{update_vars_internal}', + '{update_vars_internal}', {{vars: client_storage_vars}}, ), ); @@ -322,7 +324,7 @@ def context_template( // `on_load_internal` triggers the correct on_load event(s) for the current page. // If the page does not define any on_load event, this will just set `is_hydrated = true`. - internal_events.push(ReflexEvent('{state_name}.{on_load_internal}')); + internal_events.push(ReflexEvent('{on_load_internal}')); return internal_events; }} diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 6a0f71ec161..55751c4a41f 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -3,6 +3,9 @@ from enum import Enum from types import SimpleNamespace +# The name of the setvar event handler. +SETVAR = "setvar" + class Endpoint(Enum): """Endpoints for the reflex backend API.""" diff --git a/reflex/state.py b/reflex/state.py index 95265142727..f0187decad6 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -609,6 +609,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): **cls.computed_vars, } cls.event_handlers = {} + cls._event_id_to_name = {} # Setup the base vars at the class level. for name, prop in cls.base_vars.items(): @@ -651,16 +652,9 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): cls.event_handlers[name] = handler setattr(cls, name, handler) - # Build event_id registry from minify.json configuration - from reflex.minify import get_event_id, get_state_full_path, is_minify_enabled - - cls._event_id_to_name = {} - if is_minify_enabled(): - state_path = get_state_full_path(cls) - for handler_name in events: - event_id = get_event_id(state_path, handler_name) - if event_id is not None: - cls._event_id_to_name[event_id] = handler_name + # Register user-defined event handlers for minification + for handler_name in events: + cls._register_event_handler_for_minify(handler_name) # Initialize per-class var dependency tracking. cls._var_dependencies = {} @@ -673,16 +667,42 @@ def _add_event_handler( cls, name: str, fn: Callable, - ): + ) -> EventHandler: """Add an event handler dynamically to the state. Args: name: The name of the event handler. fn: The function to call when the event is triggered. + + Returns: + The created EventHandler instance. """ handler = cls._create_event_handler(fn) cls.event_handlers[name] = handler setattr(cls, name, handler) + cls._register_event_handler_for_minify(name) + return handler + + @classmethod + def _register_event_handler_for_minify(cls, handler_name: str) -> None: + """Register an event handler for minification if applicable. + + Called when an event handler is added to event_handlers dict. + Updates _event_id_to_name if minification is enabled and the handler + has a minified ID in the config. + + Args: + handler_name: The original name of the event handler. + """ + from reflex.minify import get_event_id, get_minify_config, get_state_full_path + + if get_minify_config() is None: + return + + state_path = get_state_full_path(cls) + event_id = get_event_id(state_path, handler_name) + if event_id is not None: + cls._event_id_to_name[event_id] = handler_name @staticmethod def _copy_fn(fn: Callable) -> Callable: @@ -1204,7 +1224,10 @@ def _create_event_handler( @classmethod def _create_setvar(cls): """Create the setvar method for the state.""" - cls.setvar = cls.event_handlers["setvar"] = EventHandlerSetVar(state_cls=cls) + cls.setvar = cls.event_handlers[constants.event.SETVAR] = EventHandlerSetVar( + state_cls=cls + ) + cls._register_event_handler_for_minify(constants.event.SETVAR) @classmethod def _create_setter(cls, name: str, prop: Var): @@ -1244,6 +1267,7 @@ def __call__(self, *args, **kwargs): ) cls.event_handlers[setter_name] = event_handler setattr(cls, setter_name, event_handler) + cls._register_event_handler_for_minify(setter_name) @classmethod def _set_default_value(cls, name: str, prop: Var): diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 62680716cda..5ccdb86d810 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -6,7 +6,8 @@ import json import os import re -from typing import TYPE_CHECKING, Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast from reflex import constants from reflex.constants.state import FRONTEND_EVENT_STATE @@ -439,7 +440,9 @@ def format_props(*single_props, **key_value_props) -> list[str]: ] + [(f"...{LiteralVar.create(prop)!s}") for prop in single_props] -def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: +def get_event_handler_parts( + handler: EventHandler | Callable[..., Any], +) -> tuple[str, str]: """Get the state and function name of an event handler. Args: @@ -448,9 +451,13 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: Returns: The state and function name (possibly minified based on minify.json). """ + from reflex.event import EventHandler from reflex.minify import is_minify_enabled from reflex.state import State + # Cast for type checker - at runtime this is always an EventHandler + handler = cast(EventHandler, handler) + # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") @@ -486,7 +493,7 @@ def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: return (state_full_name, name) -def format_event_handler(handler: EventHandler) -> str: +def format_event_handler(handler: EventHandler | Callable[..., Any]) -> str: """Format an event handler. Args: diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 41dbede7167..db9591f5386 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -419,3 +419,116 @@ def my_handler(self): # Should be the minified name directly assert event_name == "d" + + +class TestDynamicHandlerMinification: + """Tests for dynamic event handler minification (setvar, auto-setters).""" + + def test_setvar_registered_with_config(self, temp_minify_json): + """Test that setvar is registered in _event_id_to_name when config exists.""" + expected_module = "tests.units.test_minification" + expected_state_path = f"{expected_module}.State.TestStateWithSetvar" + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + expected_state_path: "b", + }, + "events": { + expected_state_path: {"setvar": "s"}, + }, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + + class TestStateWithSetvar(State): + pass + + # Verify setvar is registered for minification + assert "s" in TestStateWithSetvar._event_id_to_name + assert TestStateWithSetvar._event_id_to_name["s"] == "setvar" + + def test_auto_setter_registered_with_config(self, temp_minify_json): + """Test that auto-setters (set_*) are registered in _event_id_to_name when config exists.""" + expected_module = "tests.units.test_minification" + expected_state_path = f"{expected_module}.State.TestStateWithAutoSetter" + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + expected_state_path: "b", + }, + "events": { + expected_state_path: {"set_count": "c", "setvar": "v"}, + }, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + + class TestStateWithAutoSetter(State): + count: int = 0 + + # Verify auto-setter is registered for minification + assert "c" in TestStateWithAutoSetter._event_id_to_name + assert TestStateWithAutoSetter._event_id_to_name["c"] == "set_count" + + def test_dynamic_handlers_not_registered_without_config(self, temp_minify_json): + """Test that dynamic handlers are NOT registered when no config exists.""" + # No config saved - temp_minify_json fixture ensures clean state + + class TestStateNoConfig(State): + count: int = 0 + + # Without config, _event_id_to_name should be empty + assert TestStateNoConfig._event_id_to_name == {} + + def test_add_event_handler_registered_with_config(self, temp_minify_json): + """Test that dynamically added event handlers via _add_event_handler are registered.""" + import reflex as rx + + expected_module = "tests.units.test_minification" + expected_state_path = f"{expected_module}.State.TestStateWithDynamicHandler" + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + expected_state_path: "b", + }, + "events": { + expected_state_path: {"dynamic_handler": "d", "setvar": "v"}, + }, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + + class TestStateWithDynamicHandler(State): + pass + + # Dynamically add an event handler after class creation + @rx.event + def dynamic_handler(self): + pass + + from reflex.event import EventHandler + + handler = EventHandler( + fn=dynamic_handler, + state_full_name=TestStateWithDynamicHandler.get_full_name(), + ) + TestStateWithDynamicHandler._add_event_handler("dynamic_handler", handler) + + # Verify dynamic handler is registered for minification + assert "d" in TestStateWithDynamicHandler._event_id_to_name + assert TestStateWithDynamicHandler._event_id_to_name["d"] == "dynamic_handler" From fc787e6afa79141bd27c1de0d0d87640e074aa94 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 23:18:12 +0100 Subject: [PATCH 16/28] forgot to remove this state prefix --- reflex/.templates/web/utils/state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index a3a76fe60ae..2124d802d6d 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -1046,7 +1046,7 @@ export const useEventLoop = ( if (storage_to_state_map[e.key]) { const vars = {}; vars[storage_to_state_map[e.key]] = e.newValue; - const event = ReflexEvent(`${state_name}.${update_vars_internal}`, { + const event = ReflexEvent(update_vars_internal, { vars: vars, }); addEvents([event], e); From 5b6e124de6dbeb8aed18c1282f20fc0fe72bef9a Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 1 Feb 2026 23:24:13 +0100 Subject: [PATCH 17/28] more streamline with format event handler --- reflex/.templates/web/utils/state.js | 6 +++--- reflex/compiler/templates.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 2124d802d6d..1affca62073 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -16,7 +16,7 @@ import { initialState, onLoadInternalEvent, state_name, - exception_state_name, + handle_frontend_exception, main_state_name, update_vars_internal, } from "$/utils/context"; @@ -969,7 +969,7 @@ export const useEventLoop = ( window.onerror = function (msg, url, lineNo, columnNo, error) { addEvents([ - ReflexEvent(`${exception_state_name}.handle_frontend_exception`, { + ReflexEvent(handle_frontend_exception, { info: error.name + ": " + error.message + "\n" + error.stack, component_stack: "", }), @@ -981,7 +981,7 @@ export const useEventLoop = ( //https://github.com/mknichel/javascript-errors?tab=readme-ov-file#promise-rejection-events window.onunhandledrejection = function (event) { addEvents([ - ReflexEvent(`${exception_state_name}.handle_frontend_exception`, { + ReflexEvent(handle_frontend_exception, { info: event.reason?.name + ": " + diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 5facfd73cf8..452f09a4b46 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -288,7 +288,9 @@ def context_template( update_vars_internal = format_event_handler( UpdateVarsInternalState.update_vars_internal ) - exception_state_full = FrontendEventExceptionState.get_full_name() + handle_frontend_exception = format_event_handler( + FrontendEventExceptionState.handle_frontend_exception + ) initial_state = initial_state or {} state_contexts_str = "".join([ @@ -304,7 +306,7 @@ def context_template( export const update_vars_internal = "{update_vars_internal}" -export const exception_state_name = "{exception_state_full}" +export const handle_frontend_exception = "{handle_frontend_exception}" // These events are triggered on initial load and each page navigation. export const onLoadInternalEvent = () => {{ @@ -343,7 +345,7 @@ def context_template( export const update_vars_internal = undefined -export const exception_state_name = undefined +export const handle_frontend_exception = undefined export const onLoadInternalEvent = () => [] From ae477aef996d80657763d64ff5f18422fa8ca68e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:02:10 +0100 Subject: [PATCH 18/28] allow to enable/disable state and event ID minification independently via env vars --- reflex/environment.py | 13 ++ reflex/minify.py | 42 +++++- reflex/reflex.py | 5 +- reflex/state.py | 18 ++- reflex/utils/format.py | 4 +- tests/units/test_minification.py | 223 +++++++++++++++++++++++++++++-- 6 files changed, 281 insertions(+), 24 deletions(-) diff --git a/reflex/environment.py b/reflex/environment.py index 279fc5f60c1..5424b71f281 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -478,6 +478,13 @@ class PathExistsFlag: ExistingPath = Annotated[Path, PathExistsFlag] +class MinifyMode(enum.Enum): + """Mode for minification of state/event IDs.""" + + ENABLED = "enabled" + DISABLED = "disabled" + + class PerformanceMode(enum.Enum): """Performance mode for the app.""" @@ -762,6 +769,12 @@ class EnvironmentVariables: # How long to opportunistically hold the redis lock in milliseconds (must be less than the token expiration). REFLEX_OPLOCK_HOLD_TIME_MS: EnvVar[int] = env_var(0) + # Whether to enable state ID minification (requires minify.json). + REFLEX_MINIFY_STATE: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + + # Whether to enable event ID minification (requires minify.json). + REFLEX_MINIFY_EVENTS: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + environment = EnvironmentVariables() diff --git a/reflex/minify.py b/reflex/minify.py index cc9a9acdc00..6b3fbbfedb6 100644 --- a/reflex/minify.py +++ b/reflex/minify.py @@ -115,12 +115,46 @@ def get_minify_config() -> MinifyConfig | None: def is_minify_enabled() -> bool: - """Check if minification is enabled. + """Check if any minification is enabled (state or event). Returns: - True if minify.json exists and is valid. + True if either state or event minification is enabled. """ - return get_minify_config() is not None + return is_state_minify_enabled() or is_event_minify_enabled() + + +@functools.cache +def is_state_minify_enabled() -> bool: + """Check if state ID minification is enabled. + + Requires both REFLEX_MINIFY_STATE=enabled and minify.json to exist. + + Returns: + True if state minification is enabled. + """ + from reflex.environment import MinifyMode, environment + + return ( + environment.REFLEX_MINIFY_STATE.get() == MinifyMode.ENABLED + and get_minify_config() is not None + ) + + +@functools.cache +def is_event_minify_enabled() -> bool: + """Check if event ID minification is enabled. + + Requires both REFLEX_MINIFY_EVENTS=enabled and minify.json to exist. + + Returns: + True if event minification is enabled. + """ + from reflex.environment import MinifyMode, environment + + return ( + environment.REFLEX_MINIFY_EVENTS.get() == MinifyMode.ENABLED + and get_minify_config() is not None + ) def get_state_id(state_full_path: str) -> str | None: @@ -175,6 +209,8 @@ def clear_config_cache() -> None: This should be called after modifying minify.json programmatically. """ get_minify_config.cache_clear() + is_state_minify_enabled.cache_clear() + is_event_minify_enabled.cache_clear() # Base-54 encoding for minified names diff --git a/reflex/reflex.py b/reflex/reflex.py index f68865c2def..c5190dd847c 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -1017,9 +1017,9 @@ def minify_list(output_json: bool): from reflex.minify import ( get_event_id, + get_minify_config, get_state_full_path, get_state_id, - is_minify_enabled, ) from reflex.state import BaseState, State from reflex.utils import prerequisites @@ -1042,7 +1042,8 @@ class StateTreeData(TypedDict): # Load the user's app to register all state classes prerequisites.get_app() - minify_enabled = is_minify_enabled() + # CLI inspection always shows config contents regardless of env var settings + minify_enabled = get_minify_config() is not None def build_state_tree(state_cls: type[BaseState]) -> StateTreeData: """Recursively build state tree data. diff --git a/reflex/state.py b/reflex/state.py index f0187decad6..a5671d398c3 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -694,9 +694,13 @@ def _register_event_handler_for_minify(cls, handler_name: str) -> None: Args: handler_name: The original name of the event handler. """ - from reflex.minify import get_event_id, get_minify_config, get_state_full_path + from reflex.minify import ( + get_event_id, + get_state_full_path, + is_event_minify_enabled, + ) - if get_minify_config() is None: + if not is_event_minify_enabled(): return state_path = get_state_full_path(cls) @@ -1030,13 +1034,17 @@ def get_name(cls) -> str: Returns: The name of the state (minified if configured in minify.json). """ - from reflex.minify import get_state_full_path, get_state_id, is_minify_enabled + from reflex.minify import ( + get_state_full_path, + get_state_id, + is_state_minify_enabled, + ) module = cls.__module__.replace(".", "___") full_name = format.to_snake_case(f"{module}___{cls.__name__}") - # If minification is enabled, look up the state ID from minify.json - if is_minify_enabled(): + # If state minification is enabled, look up the state ID from minify.json + if is_state_minify_enabled(): state_path = get_state_full_path(cls) state_id = get_state_id(state_path) if state_id is not None: diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 5ccdb86d810..4d5804dc8cb 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -452,7 +452,7 @@ def get_event_handler_parts( The state and function name (possibly minified based on minify.json). """ from reflex.event import EventHandler - from reflex.minify import is_minify_enabled + from reflex.minify import is_event_minify_enabled from reflex.state import State # Cast for type checker - at runtime this is always an EventHandler @@ -477,7 +477,7 @@ def get_event_handler_parts( # Check for event_id minification from minify.json # The state class stores its event ID mapping in _event_id_to_name # where key is minified_name and value is original_handler_name - if is_minify_enabled(): + if is_event_minify_enabled(): try: # Get the state class using the path state_cls = State.get_class_substate(state_full_name) diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index db9591f5386..4d0eedb4bff 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -16,7 +16,9 @@ get_state_full_path, get_state_id, int_to_minified_name, + is_event_minify_enabled, is_minify_enabled, + is_state_minify_enabled, minified_name_to_int, save_minify_config, sync_minify_config, @@ -135,8 +137,10 @@ def test_no_config_returns_none(self, temp_minify_json): assert get_state_id("any.path") is None assert get_event_id("any.path", "handler") is None - def test_save_and_load_config(self, temp_minify_json): + def test_save_and_load_config(self, temp_minify_json, monkeypatch): """Test saving and loading a config.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -151,8 +155,9 @@ def test_save_and_load_config(self, temp_minify_json): assert get_state_id("test.module.MyState") == "a" assert get_event_id("test.module.MyState", "handler") == "a" - def test_invalid_version_raises(self, temp_minify_json): + def test_invalid_version_raises(self, temp_minify_json, monkeypatch): """Test that invalid version raises ValueError.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") config = {"version": 999, "states": {}, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -161,10 +166,11 @@ def test_invalid_version_raises(self, temp_minify_json): clear_config_cache() with pytest.raises(ValueError, match=r"Unsupported.*version"): - is_minify_enabled() + is_state_minify_enabled() - def test_missing_states_raises(self, temp_minify_json): + def test_missing_states_raises(self, temp_minify_json, monkeypatch): """Test that missing 'states' key raises ValueError.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") config = {"version": SCHEMA_VERSION, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -173,7 +179,7 @@ def test_missing_states_raises(self, temp_minify_json): clear_config_cache() with pytest.raises(ValueError, match="'states' must be"): - is_minify_enabled() + is_state_minify_enabled() class TestGenerateMinifyConfig: @@ -319,8 +325,9 @@ class TestState(BaseState): # Should be the full name (snake_case module___class) assert "test_state" in name.lower() - def test_state_uses_minified_name_with_config(self, temp_minify_json): - """Test that states use minified names when minify.json exists.""" + def test_state_uses_minified_name_with_config(self, temp_minify_json, monkeypatch): + """Test that states use minified names when minify.json exists and env var is enabled.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") class TestState(BaseState): pass @@ -340,6 +347,31 @@ class TestState(BaseState): # Should be the minified name directly assert name == "f" + def test_state_uses_full_name_when_env_disabled( + self, temp_minify_json, monkeypatch + ): + """Test that states use full names when env var is disabled even with minify.json.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "disabled") + + class TestState(BaseState): + pass + + state_path = get_state_full_path(TestState) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {state_path: "f"}, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + TestState.get_name.cache_clear() + + name = TestState.get_name() + + # Should be the full name, not minified + assert name != "f" + assert "test_state" in name.lower() + class TestEventMinification: """Tests for event handler name minification with minify.json.""" @@ -361,11 +393,13 @@ def my_handler(self): # Should use full name assert event_name == "my_handler" - def test_event_uses_minified_name_with_config(self, temp_minify_json): - """Test that event handlers use minified names when minify.json exists.""" + def test_event_uses_minified_name_with_config(self, temp_minify_json, monkeypatch): + """Test that event handlers use minified names when minify.json exists and env var is enabled.""" import reflex as rx from reflex.utils.format import get_event_handler_parts + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + # First, set up the config BEFORE creating the state class # The event_id_to_name registry is built during __init_subclass__ # so the config must exist before the class is defined @@ -420,12 +454,57 @@ def my_handler(self): # Should be the minified name directly assert event_name == "d" + def test_event_uses_full_name_when_env_disabled( + self, temp_minify_json, monkeypatch + ): + """Test that event handlers use full names when env var is disabled even with minify.json.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "disabled") + + expected_module = "tests.units.test_minification" + expected_state_path = ( + f"{expected_module}.State.TestStateWithMinifiedEventDisabled" + ) + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + expected_state_path: "b", + }, + "events": { + expected_state_path: {"my_handler": "d"}, + }, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + + class TestStateWithMinifiedEventDisabled(State): + @rx.event + def my_handler(self): + pass + + # The state's _event_id_to_name should be empty when env var is disabled + assert TestStateWithMinifiedEventDisabled._event_id_to_name == {} + + handler = TestStateWithMinifiedEventDisabled.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # Should use full name + assert event_name == "my_handler" + class TestDynamicHandlerMinification: """Tests for dynamic event handler minification (setvar, auto-setters).""" - def test_setvar_registered_with_config(self, temp_minify_json): + def test_setvar_registered_with_config(self, temp_minify_json, monkeypatch): """Test that setvar is registered in _event_id_to_name when config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithSetvar" @@ -452,8 +531,9 @@ class TestStateWithSetvar(State): assert "s" in TestStateWithSetvar._event_id_to_name assert TestStateWithSetvar._event_id_to_name["s"] == "setvar" - def test_auto_setter_registered_with_config(self, temp_minify_json): + def test_auto_setter_registered_with_config(self, temp_minify_json, monkeypatch): """Test that auto-setters (set_*) are registered in _event_id_to_name when config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithAutoSetter" @@ -490,10 +570,13 @@ class TestStateNoConfig(State): # Without config, _event_id_to_name should be empty assert TestStateNoConfig._event_id_to_name == {} - def test_add_event_handler_registered_with_config(self, temp_minify_json): + def test_add_event_handler_registered_with_config( + self, temp_minify_json, monkeypatch + ): """Test that dynamically added event handlers via _add_event_handler are registered.""" import reflex as rx + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithDynamicHandler" @@ -532,3 +615,119 @@ def dynamic_handler(self): # Verify dynamic handler is registered for minification assert "d" in TestStateWithDynamicHandler._event_id_to_name assert TestStateWithDynamicHandler._event_id_to_name["d"] == "dynamic_handler" + + +class TestMinifyModeEnvVars: + """Tests for REFLEX_MINIFY_STATE and REFLEX_MINIFY_EVENTS env vars.""" + + def test_state_minify_disabled_by_default(self, temp_minify_json): + """Test that state minification is disabled by default.""" + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + + assert is_state_minify_enabled() is False + + def test_event_minify_disabled_by_default(self, temp_minify_json): + """Test that event minification is disabled by default.""" + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + clear_config_cache() + + assert is_event_minify_enabled() is False + + def test_state_minify_enabled_with_env_and_config( + self, temp_minify_json, monkeypatch + ): + """Test that state minification is enabled when env var is enabled and config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + + assert is_state_minify_enabled() is True + + def test_event_minify_enabled_with_env_and_config( + self, temp_minify_json, monkeypatch + ): + """Test that event minification is enabled when env var is enabled and config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + clear_config_cache() + + assert is_event_minify_enabled() is True + + def test_state_minify_disabled_without_config(self, temp_minify_json, monkeypatch): + """Test that state minification is disabled when env var is enabled but no config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + clear_config_cache() + + assert is_state_minify_enabled() is False + + def test_event_minify_disabled_without_config(self, temp_minify_json, monkeypatch): + """Test that event minification is disabled when env var is enabled but no config exists.""" + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + clear_config_cache() + + assert is_event_minify_enabled() is False + + def test_independent_state_and_event_toggles(self, temp_minify_json, monkeypatch): + """Test that state and event minification can be toggled independently.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "disabled") + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + clear_config_cache() + + assert is_state_minify_enabled() is True + assert is_event_minify_enabled() is False + assert is_minify_enabled() is True + + def test_is_minify_enabled_true_when_either_enabled( + self, temp_minify_json, monkeypatch + ): + """Test that is_minify_enabled returns True when either state or event is enabled.""" + monkeypatch.setenv("REFLEX_MINIFY_STATE", "disabled") + monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + clear_config_cache() + + assert is_minify_enabled() is True + + def test_is_minify_enabled_false_when_both_disabled(self, temp_minify_json): + """Test that is_minify_enabled returns False when both are disabled (default).""" + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + clear_config_cache() + + assert is_minify_enabled() is False From e13073b844c11d69362e4781b61c755d261ea25e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:20:32 +0100 Subject: [PATCH 19/28] fix: prevent parent-child minified name collision in substate resolution When a parent and child state have the same minified name, substate resolution can fail because the leading segment is stripped incorrectly. This change adds a flag to skip stripping only on the initial recursive call, ensuring correct resolution even in name collision scenarios. --- reflex/state.py | 22 +++++-- tests/units/test_minification.py | 101 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index a5671d398c3..e3b96dec216 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1068,11 +1068,17 @@ def get_full_name(cls) -> str: @classmethod @functools.lru_cache - def get_class_substate(cls, path: Sequence[str] | str) -> type[BaseState]: + def get_class_substate( + cls, path: Sequence[str] | str, _skip_self: bool = True + ) -> type[BaseState]: """Get the class substate. Args: path: The path to the substate. + _skip_self: If True, strip the leading segment when it matches this + state's name. Only the initial (root) call should use True; + recursive calls pass False so that a child whose minified name + collides with its parent is resolved correctly. Returns: The class substate. @@ -1085,13 +1091,13 @@ def get_class_substate(cls, path: Sequence[str] | str) -> type[BaseState]: if len(path) == 0: return cls - if path[0] == cls.get_name(): + if _skip_self and path[0] == cls.get_name(): if len(path) == 1: return cls path = path[1:] for substate in cls.get_substates(): if path[0] == substate.get_name(): - return substate.get_class_substate(path[1:]) + return substate.get_class_substate(path[1:], _skip_self=False) msg = f"Invalid path: {path}" raise ValueError(msg) @@ -1605,11 +1611,15 @@ def _reset_client_storage(self): for substate in self.substates.values(): substate._reset_client_storage() - def get_substate(self, path: Sequence[str]) -> BaseState: + def get_substate(self, path: Sequence[str], _skip_self: bool = True) -> BaseState: """Get the substate. Args: path: The path to the substate. + _skip_self: If True, strip the leading segment when it matches this + state's name. Only the initial (root) call should use True; + recursive calls pass False so that a child whose minified name + collides with its parent is resolved correctly. Returns: The substate. @@ -1619,14 +1629,14 @@ def get_substate(self, path: Sequence[str]) -> BaseState: """ if len(path) == 0: return self - if path[0] == self.get_name(): + if _skip_self and path[0] == self.get_name(): if len(path) == 1: return self path = path[1:] if path[0] not in self.substates: msg = f"Invalid path: {path}" raise ValueError(msg) - return self.substates[path[0]].get_substate(path[1:]) + return self.substates[path[0]].get_substate(path[1:], _skip_self=False) @classmethod def _get_potentially_dirty_states(cls) -> set[type[BaseState]]: diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 4d0eedb4bff..b8954760a5a 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -731,3 +731,104 @@ def test_is_minify_enabled_false_when_both_disabled(self, temp_minify_json): clear_config_cache() assert is_minify_enabled() is False + + +class TestMinifiedNameCollision: + """Tests for parent-child minified name collision in substate resolution.""" + + def test_get_class_substate_with_parent_child_name_collision( + self, temp_minify_json, monkeypatch + ): + """Test that get_class_substate resolves correctly when parent and child + share the same minified name (IDs are only sibling-unique). + """ + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + + # Build a hierarchy: State -> ParentState -> ChildState + # where ParentState and ChildState both get minified name "b" + + class ParentState(State): + pass + + class ChildState(ParentState): + pass + + parent_path = get_state_full_path(ParentState) + child_path = get_state_full_path(ChildState) + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + parent_path: "b", + child_path: "b", # Same minified name as parent + }, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + ParentState.get_name.cache_clear() + ParentState.get_full_name.cache_clear() + ChildState.get_name.cache_clear() + ChildState.get_full_name.cache_clear() + + # Verify both get the same minified name + assert ParentState.get_name() == "b" + assert ChildState.get_name() == "b" + + # Full path should be a.b.b + assert ChildState.get_full_name() == "a.b.b" + + # get_class_substate should resolve a.b.b to ChildState, not ParentState + resolved = State.get_class_substate("a.b.b") + assert resolved is ChildState + + def test_get_substate_with_parent_child_name_collision( + self, temp_minify_json, monkeypatch + ): + """Test that get_substate (instance method) resolves correctly when parent + and child share the same minified name. + """ + import reflex as rx + + monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + + class ParentState2(State): + pass + + class ChildState2(ParentState2): + @rx.event + def my_handler(self): + pass + + parent_path = get_state_full_path(ParentState2) + child_path = get_state_full_path(ChildState2) + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + parent_path: "b", + child_path: "b", # Same minified name as parent + }, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + State.get_full_name.cache_clear() + State.get_class_substate.cache_clear() + ParentState2.get_name.cache_clear() + ParentState2.get_full_name.cache_clear() + ChildState2.get_name.cache_clear() + ChildState2.get_full_name.cache_clear() + + # Create a state instance tree + root = State(_reflex_internal_init=True) # type: ignore[call-arg] + + # Instance get_substate should resolve a.b.b to ChildState2 + resolved = root.get_substate(["a", "b", "b"]) + assert type(resolved) is ChildState2 From d669571abc11b703b277b2e59af496182e32e277 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:42:30 +0100 Subject: [PATCH 20/28] fix: consistent plural naming for state minification env vars --- reflex/environment.py | 2 +- reflex/minify.py | 2 +- tests/units/test_minification.py | 45 ++++++++++++++++---------------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/reflex/environment.py b/reflex/environment.py index 5424b71f281..1b074b47b26 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -770,7 +770,7 @@ class EnvironmentVariables: REFLEX_OPLOCK_HOLD_TIME_MS: EnvVar[int] = env_var(0) # Whether to enable state ID minification (requires minify.json). - REFLEX_MINIFY_STATE: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) + REFLEX_MINIFY_STATES: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) # Whether to enable event ID minification (requires minify.json). REFLEX_MINIFY_EVENTS: EnvVar[MinifyMode] = env_var(MinifyMode.DISABLED) diff --git a/reflex/minify.py b/reflex/minify.py index 6b3fbbfedb6..7794d6198c3 100644 --- a/reflex/minify.py +++ b/reflex/minify.py @@ -135,7 +135,7 @@ def is_state_minify_enabled() -> bool: from reflex.environment import MinifyMode, environment return ( - environment.REFLEX_MINIFY_STATE.get() == MinifyMode.ENABLED + environment.REFLEX_MINIFY_STATES.get() == MinifyMode.ENABLED and get_minify_config() is not None ) diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index b8954760a5a..197b2942441 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -6,6 +6,7 @@ import pytest +from reflex.environment import environment from reflex.minify import ( MINIFY_JSON, SCHEMA_VERSION, @@ -139,8 +140,8 @@ def test_no_config_returns_none(self, temp_minify_json): def test_save_and_load_config(self, temp_minify_json, monkeypatch): """Test saving and loading a config.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -157,7 +158,7 @@ def test_save_and_load_config(self, temp_minify_json, monkeypatch): def test_invalid_version_raises(self, temp_minify_json, monkeypatch): """Test that invalid version raises ValueError.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") config = {"version": 999, "states": {}, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -170,7 +171,7 @@ def test_invalid_version_raises(self, temp_minify_json, monkeypatch): def test_missing_states_raises(self, temp_minify_json, monkeypatch): """Test that missing 'states' key raises ValueError.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") config = {"version": SCHEMA_VERSION, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -327,7 +328,7 @@ class TestState(BaseState): def test_state_uses_minified_name_with_config(self, temp_minify_json, monkeypatch): """Test that states use minified names when minify.json exists and env var is enabled.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") class TestState(BaseState): pass @@ -351,7 +352,7 @@ def test_state_uses_full_name_when_env_disabled( self, temp_minify_json, monkeypatch ): """Test that states use full names when env var is disabled even with minify.json.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "disabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "disabled") class TestState(BaseState): pass @@ -398,7 +399,7 @@ def test_event_uses_minified_name_with_config(self, temp_minify_json, monkeypatc import reflex as rx from reflex.utils.format import get_event_handler_parts - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") # First, set up the config BEFORE creating the state class # The event_id_to_name registry is built during __init_subclass__ @@ -461,7 +462,7 @@ def test_event_uses_full_name_when_env_disabled( import reflex as rx from reflex.utils.format import get_event_handler_parts - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "disabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "disabled") expected_module = "tests.units.test_minification" expected_state_path = ( @@ -504,7 +505,7 @@ class TestDynamicHandlerMinification: def test_setvar_registered_with_config(self, temp_minify_json, monkeypatch): """Test that setvar is registered in _event_id_to_name when config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithSetvar" @@ -533,7 +534,7 @@ class TestStateWithSetvar(State): def test_auto_setter_registered_with_config(self, temp_minify_json, monkeypatch): """Test that auto-setters (set_*) are registered in _event_id_to_name when config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithAutoSetter" @@ -576,7 +577,7 @@ def test_add_event_handler_registered_with_config( """Test that dynamically added event handlers via _add_event_handler are registered.""" import reflex as rx - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithDynamicHandler" @@ -618,7 +619,7 @@ def dynamic_handler(self): class TestMinifyModeEnvVars: - """Tests for REFLEX_MINIFY_STATE and REFLEX_MINIFY_EVENTS env vars.""" + """Tests for REFLEX_MINIFY_STATES and REFLEX_MINIFY_EVENTS env vars.""" def test_state_minify_disabled_by_default(self, temp_minify_json): """Test that state minification is disabled by default.""" @@ -648,7 +649,7 @@ def test_state_minify_enabled_with_env_and_config( self, temp_minify_json, monkeypatch ): """Test that state minification is enabled when env var is enabled and config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -663,7 +664,7 @@ def test_event_minify_enabled_with_env_and_config( self, temp_minify_json, monkeypatch ): """Test that event minification is enabled when env var is enabled and config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {}, @@ -676,22 +677,22 @@ def test_event_minify_enabled_with_env_and_config( def test_state_minify_disabled_without_config(self, temp_minify_json, monkeypatch): """Test that state minification is disabled when env var is enabled but no config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") clear_config_cache() assert is_state_minify_enabled() is False def test_event_minify_disabled_without_config(self, temp_minify_json, monkeypatch): """Test that event minification is disabled when env var is enabled but no config exists.""" - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") clear_config_cache() assert is_event_minify_enabled() is False def test_independent_state_and_event_toggles(self, temp_minify_json, monkeypatch): """Test that state and event minification can be toggled independently.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "disabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "disabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -708,8 +709,8 @@ def test_is_minify_enabled_true_when_either_enabled( self, temp_minify_json, monkeypatch ): """Test that is_minify_enabled returns True when either state or event is enabled.""" - monkeypatch.setenv("REFLEX_MINIFY_STATE", "disabled") - monkeypatch.setenv("REFLEX_MINIFY_EVENTS", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "disabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {}, @@ -742,7 +743,7 @@ def test_get_class_substate_with_parent_child_name_collision( """Test that get_class_substate resolves correctly when parent and child share the same minified name (IDs are only sibling-unique). """ - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") # Build a hierarchy: State -> ParentState -> ChildState # where ParentState and ChildState both get minified name "b" @@ -794,7 +795,7 @@ def test_get_substate_with_parent_child_name_collision( """ import reflex as rx - monkeypatch.setenv("REFLEX_MINIFY_STATE", "enabled") + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") class ParentState2(State): pass From ed085c064ed8515efd706f5affa5e8a61ffcfa1a Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:45:27 +0100 Subject: [PATCH 21/28] make integration tests use our typed env helpers --- tests/integration/test_minification.py | 9 ++- tests/units/test_minification.py | 86 +++++++++++++++++++------- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py index 8a4d057d85e..ad2e94e5684 100644 --- a/tests/integration/test_minification.py +++ b/tests/integration/test_minification.py @@ -9,6 +9,7 @@ import pytest from selenium.webdriver.common.by import By +from reflex.environment import MinifyMode, environment from reflex.minify import MINIFY_JSON, clear_config_cache, int_to_minified_name from reflex.testing import AppHarness @@ -119,16 +120,22 @@ def minify_disabled_app( def minify_enabled_app( app_harness_env: type[AppHarness], tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch, ) -> Generator[AppHarness, None, None]: - """Start app WITH minify.json (minified names). + """Start app WITH minify.json and env vars enabled (minified names). Args: app_harness_env: AppHarness or AppHarnessProd tmp_path_factory: pytest tmp_path_factory fixture + monkeypatch: pytest monkeypatch fixture Yields: Running AppHarness instance """ + # Enable minification via env vars (required in addition to minify.json) + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value) + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value) + # Clear minify config cache to ensure clean state clear_config_cache() diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index 197b2942441..ca40d2a0be3 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -6,7 +6,7 @@ import pytest -from reflex.environment import environment +from reflex.environment import MinifyMode, environment from reflex.minify import ( MINIFY_JSON, SCHEMA_VERSION, @@ -140,8 +140,12 @@ def test_no_config_returns_none(self, temp_minify_json): def test_save_and_load_config(self, temp_minify_json, monkeypatch): """Test saving and loading a config.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -158,7 +162,9 @@ def test_save_and_load_config(self, temp_minify_json, monkeypatch): def test_invalid_version_raises(self, temp_minify_json, monkeypatch): """Test that invalid version raises ValueError.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) config = {"version": 999, "states": {}, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -171,7 +177,9 @@ def test_invalid_version_raises(self, temp_minify_json, monkeypatch): def test_missing_states_raises(self, temp_minify_json, monkeypatch): """Test that missing 'states' key raises ValueError.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) config = {"version": SCHEMA_VERSION, "events": {}} path = temp_minify_json / MINIFY_JSON with path.open("w") as f: @@ -328,7 +336,9 @@ class TestState(BaseState): def test_state_uses_minified_name_with_config(self, temp_minify_json, monkeypatch): """Test that states use minified names when minify.json exists and env var is enabled.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) class TestState(BaseState): pass @@ -352,7 +362,9 @@ def test_state_uses_full_name_when_env_disabled( self, temp_minify_json, monkeypatch ): """Test that states use full names when env var is disabled even with minify.json.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "disabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.DISABLED.value + ) class TestState(BaseState): pass @@ -399,7 +411,9 @@ def test_event_uses_minified_name_with_config(self, temp_minify_json, monkeypatc import reflex as rx from reflex.utils.format import get_event_handler_parts - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) # First, set up the config BEFORE creating the state class # The event_id_to_name registry is built during __init_subclass__ @@ -462,7 +476,9 @@ def test_event_uses_full_name_when_env_disabled( import reflex as rx from reflex.utils.format import get_event_handler_parts - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "disabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.DISABLED.value + ) expected_module = "tests.units.test_minification" expected_state_path = ( @@ -505,7 +521,9 @@ class TestDynamicHandlerMinification: def test_setvar_registered_with_config(self, temp_minify_json, monkeypatch): """Test that setvar is registered in _event_id_to_name when config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithSetvar" @@ -534,7 +552,9 @@ class TestStateWithSetvar(State): def test_auto_setter_registered_with_config(self, temp_minify_json, monkeypatch): """Test that auto-setters (set_*) are registered in _event_id_to_name when config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithAutoSetter" @@ -577,7 +597,9 @@ def test_add_event_handler_registered_with_config( """Test that dynamically added event handlers via _add_event_handler are registered.""" import reflex as rx - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) expected_module = "tests.units.test_minification" expected_state_path = f"{expected_module}.State.TestStateWithDynamicHandler" @@ -649,7 +671,9 @@ def test_state_minify_enabled_with_env_and_config( self, temp_minify_json, monkeypatch ): """Test that state minification is enabled when env var is enabled and config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -664,7 +688,9 @@ def test_event_minify_enabled_with_env_and_config( self, temp_minify_json, monkeypatch ): """Test that event minification is enabled when env var is enabled and config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {}, @@ -677,22 +703,30 @@ def test_event_minify_enabled_with_env_and_config( def test_state_minify_disabled_without_config(self, temp_minify_json, monkeypatch): """Test that state minification is disabled when env var is enabled but no config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) clear_config_cache() assert is_state_minify_enabled() is False def test_event_minify_disabled_without_config(self, temp_minify_json, monkeypatch): """Test that event minification is disabled when env var is enabled but no config exists.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) clear_config_cache() assert is_event_minify_enabled() is False def test_independent_state_and_event_toggles(self, temp_minify_json, monkeypatch): """Test that state and event minification can be toggled independently.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "disabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.DISABLED.value + ) config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {"test.module.MyState": "a"}, @@ -709,8 +743,12 @@ def test_is_minify_enabled_true_when_either_enabled( self, temp_minify_json, monkeypatch ): """Test that is_minify_enabled returns True when either state or event is enabled.""" - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "disabled") - monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.DISABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) config: MinifyConfig = { "version": SCHEMA_VERSION, "states": {}, @@ -743,7 +781,9 @@ def test_get_class_substate_with_parent_child_name_collision( """Test that get_class_substate resolves correctly when parent and child share the same minified name (IDs are only sibling-unique). """ - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) # Build a hierarchy: State -> ParentState -> ChildState # where ParentState and ChildState both get minified name "b" @@ -795,7 +835,9 @@ def test_get_substate_with_parent_child_name_collision( """ import reflex as rx - monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, "enabled") + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) class ParentState2(State): pass From b2c2ff064fdaffd58d3fe5c361930d66e5c8c5a0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:47:55 +0100 Subject: [PATCH 22/28] prefer assert isinstance over cast. --- reflex/utils/format.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 4d5804dc8cb..64289a7c65d 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -7,7 +7,7 @@ import os import re from collections.abc import Callable -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from reflex import constants from reflex.constants.state import FRONTEND_EVENT_STATE @@ -455,8 +455,9 @@ def get_event_handler_parts( from reflex.minify import is_event_minify_enabled from reflex.state import State - # Cast for type checker - at runtime this is always an EventHandler - handler = cast(EventHandler, handler) + assert isinstance(handler, EventHandler), ( + f"Expected EventHandler, got {type(handler)}" + ) # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") From 8f44d49b86adaa83b040335fcef523fa4e843e84 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:51:00 +0100 Subject: [PATCH 23/28] drop old state_id parameter from BaseStateMeta --- reflex/vars/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/reflex/vars/base.py b/reflex/vars/base.py index 549b4896d57..8a8f0e754d1 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -3540,7 +3540,6 @@ def __new__( bases: tuple[type], namespace: dict[str, Any], mixin: bool = False, - state_id: int | None = None, ) -> type: """Create a new class. @@ -3549,7 +3548,6 @@ def __new__( bases: The bases of the class. namespace: The namespace of the class. mixin: Whether the class is a mixin and should not be instantiated. - state_id: Explicit state ID for minified state names. Returns: The new class. @@ -3650,9 +3648,6 @@ def __new__( namespace["__inherited_fields__"] = inherited_fields namespace["__fields__"] = inherited_fields | own_fields namespace["_mixin"] = mixin - # Pass state_id to __init_subclass__ if provided (for BaseState subclasses) - if state_id is not None: - return super().__new__(cls, name, bases, namespace, state_id=state_id) return super().__new__(cls, name, bases, namespace) From cf7b614d9fc1dc0e3b45211132d12506c95436ae Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 21:51:59 +0100 Subject: [PATCH 24/28] fix: drop unused EVENT_ID_MARKER constant --- reflex/event.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 825370cfa2f..ff75e3bd3cb 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -89,7 +89,6 @@ def substate_token(self) -> str: _EVENT_FIELDS: set[str] = {f.name for f in dataclasses.fields(Event)} BACKGROUND_TASK_MARKER = "_reflex_background_task" -EVENT_ID_MARKER = "_rx_event_id" EVENT_ACTIONS_MARKER = "_rx_event_actions" @@ -2313,7 +2312,6 @@ class EventNamespace: # Constants BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER - EVENT_ID_MARKER = EVENT_ID_MARKER EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA From 3074c84e5badad414a8cb5e6993521925dcadbaa Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 4 Feb 2026 22:35:05 +0100 Subject: [PATCH 25/28] fix: correctly count events in minification cli --- reflex/reflex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index c5190dd847c..6800f5243c4 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -881,7 +881,7 @@ def minify_init(): save_minify_config(config) num_states = len(config["states"]) - num_events = len(config["events"]) + num_events = sum(len(handlers) for handlers in config["events"].values()) console.log( f"Created {MINIFY_JSON} with {num_states} states and {num_events} events." ) @@ -931,7 +931,7 @@ def minify_sync(reassign_deleted: bool, prune: bool): raise SystemExit(1) old_states = len(existing_config["states"]) - old_events = len(existing_config["events"]) + old_events = sum(len(handlers) for handlers in existing_config["events"].values()) # Sync the configuration new_config = sync_minify_config( @@ -940,7 +940,7 @@ def minify_sync(reassign_deleted: bool, prune: bool): save_minify_config(new_config) new_states = len(new_config["states"]) - new_events = len(new_config["events"]) + new_events = sum(len(handlers) for handlers in new_config["events"].values()) console.log(f"Updated {MINIFY_JSON}:") console.log(f" States: {old_states} -> {new_states}") From ca73b45a597c2e09844f1d6d5347d926ed8423da Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 6 Feb 2026 01:22:09 +0100 Subject: [PATCH 26/28] fix: adjust minify lookup cli for new minify.json, add tests --- reflex/reflex.py | 89 +++++++++++++------ tests/units/test_minification.py | 145 +++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 25 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index 6800f5243c4..64cf161701a 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -1160,49 +1160,88 @@ def minify_lookup(output_json: bool, minified_path: str): Walks the state tree from the root to resolve each segment. """ - from reflex.minify import get_state_full_path, get_state_id - from reflex.state import State + from reflex.minify import MINIFY_JSON, get_minify_config, get_state_full_path + from reflex.state import BaseState, State from reflex.utils import prerequisites # Load the user's app to register all state classes prerequisites.get_app() - try: - State.get_class_substate(minified_path) - except ValueError: - msg = f"No state found for path: {minified_path}" - console.error(msg) - raise ValueError(msg) from None + config = get_minify_config() + if config is None: + console.error( + f"{MINIFY_JSON} not found. Run 'reflex minify init' to create it." + ) + raise SystemExit(1) + + def collect_states( + state_cls: type[BaseState], + ) -> list[type[BaseState]]: + """Recursively collect all states. + + Args: + state_cls: The state class to start from. - # Build info for each ancestor segment + Returns: + List of all state classes in the hierarchy. + """ + result = [state_cls] + for sub in state_cls.class_subclasses: + result.extend(collect_states(sub)) + return result + + # Build lookup: full_path -> (state_class, minified_id) + all_states = collect_states(State) + path_to_info: dict[str, tuple[type[BaseState], str | None]] = {} + for state_cls in all_states: + full_path = get_state_full_path(state_cls) + minified_id = config["states"].get(full_path) + path_to_info[full_path] = (state_cls, minified_id) + + # Walk the minified path parts = minified_path.split(".") result_parts = [] current = State - state_path = get_state_full_path(current) - state_id = get_state_id(state_path) - result_parts.append({ - "minified": parts[0], - "state_id": state_id, - "module": current.__module__, - "class": current.__name__, - "full_path": state_path, - }) - for part in parts[1:]: - current = current.get_class_substate(part) - state_path = get_state_full_path(current) - state_id = get_state_id(state_path) + + for i, part in enumerate(parts): + # Find state whose minified ID matches 'part' + found = None + if i == 0: + # First segment should match root state + state_path = get_state_full_path(current) + _, state_id = path_to_info.get(state_path, (None, None)) + if state_id == part: + found = current + else: + # Find among children of current + for child in current.class_subclasses: + child_path = get_state_full_path(child) + _, child_id = path_to_info.get(child_path, (None, None)) + if child_id == part: + found = child + break + + if found is None: + console.error( + f"No state found for minified segment '{part}' in path '{minified_path}'" + ) + raise SystemExit(1) + + state_path = get_state_full_path(found) + _, state_id = path_to_info.get(state_path, (None, None)) result_parts.append({ "minified": part, "state_id": state_id, - "module": current.__module__, - "class": current.__name__, + "module": found.__module__, + "class": found.__name__, "full_path": state_path, }) + current = found if output_json: import json - console.log(json.dumps(result_parts, indent=2)) + click.echo(json.dumps(result_parts, indent=2)) else: # Simple output: module.ClassName for each part for info in result_parts: diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py index ca40d2a0be3..a941d175bc8 100644 --- a/tests/units/test_minification.py +++ b/tests/units/test_minification.py @@ -875,3 +875,148 @@ def my_handler(self): # Instance get_substate should resolve a.b.b to ChildState2 resolved = root.get_substate(["a", "b", "b"]) assert type(resolved) is ChildState2 + + +class TestMinifyLookupCLI: + """Tests for the 'reflex minify lookup' CLI command.""" + + def test_lookup_resolves_minified_path(self, temp_minify_json, monkeypatch): + """Test that lookup resolves a minified path to full state info.""" + from unittest import mock + + from click.testing import CliRunner + + from reflex.reflex import cli + from reflex.utils import prerequisites + + # Create test states + class AppState(State): + pass + + class ChildState(AppState): + pass + + app_state_path = get_state_full_path(AppState) + child_state_path = get_state_full_path(ChildState) + + # Create minify.json with known mappings + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + app_state_path: "b", + child_state_path: "c", + }, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + + # Mock prerequisites.get_app to avoid needing a real app + app_module_mock = mock.Mock() + monkeypatch.setattr(prerequisites, "get_app", lambda *a, **kw: app_module_mock) + + runner = CliRunner() + result = runner.invoke(cli, ["minify", "lookup", "a.b.c"]) + + assert result.exit_code == 0, result.output + # Output should include the module and class names + assert "State" in result.output + assert "AppState" in result.output + assert "ChildState" in result.output + + def test_lookup_fails_without_minify_json(self, temp_minify_json, monkeypatch): + """Test that lookup fails gracefully when minify.json is missing.""" + from unittest import mock + + from click.testing import CliRunner + + from reflex.reflex import cli + from reflex.utils import prerequisites + + # Mock prerequisites.get_app + app_module_mock = mock.Mock() + monkeypatch.setattr(prerequisites, "get_app", lambda *a, **kw: app_module_mock) + + # Don't create minify.json + clear_config_cache() + + runner = CliRunner() + result = runner.invoke(cli, ["minify", "lookup", "a.b"]) + + assert result.exit_code == 1 + assert "minify.json not found" in result.output + + def test_lookup_fails_for_invalid_path(self, temp_minify_json, monkeypatch): + """Test that lookup fails for non-existent minified path.""" + from unittest import mock + + from click.testing import CliRunner + + from reflex.reflex import cli + from reflex.utils import prerequisites + + # Create minify.json with only root state + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + }, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + + # Mock prerequisites.get_app + app_module_mock = mock.Mock() + monkeypatch.setattr(prerequisites, "get_app", lambda *a, **kw: app_module_mock) + + runner = CliRunner() + # Try to lookup a path that doesn't exist + result = runner.invoke(cli, ["minify", "lookup", "a.xyz"]) + + assert result.exit_code == 1 + assert "No state found" in result.output + + def test_lookup_with_json_output(self, temp_minify_json, monkeypatch): + """Test that lookup with --json flag outputs valid JSON.""" + from unittest import mock + + from click.testing import CliRunner + + from reflex.reflex import cli + from reflex.utils import prerequisites + + # Create test state + class JsonTestState(State): + pass + + state_path = get_state_full_path(JsonTestState) + + # Create minify.json + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "reflex.state.State": "a", + state_path: "b", + }, + "events": {}, + } + save_minify_config(config) + clear_config_cache() + + # Mock prerequisites.get_app + app_module_mock = mock.Mock() + monkeypatch.setattr(prerequisites, "get_app", lambda *a, **kw: app_module_mock) + + runner = CliRunner() + result = runner.invoke(cli, ["minify", "lookup", "--json", "a.b"]) + + assert result.exit_code == 0, result.output + # Parse output as JSON to verify it's valid + output_data = json.loads(result.output) + assert isinstance(output_data, list) + assert len(output_data) == 2 # Root state + JsonTestState + assert output_data[0]["class"] == "State" + assert output_data[1]["class"] == "JsonTestState" + assert output_data[1]["state_id"] == "b" From d5f159ca8ae2ef81e5c2e06c623fee4a165bafc8 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 6 Feb 2026 01:58:31 +0100 Subject: [PATCH 27/28] fix: reflex minify validate - warn and exit 1 if missing entries --- reflex/reflex.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index 64cf161701a..f54ccbdce2e 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -995,12 +995,11 @@ def minify_validate(): if missing: console.info("Missing entries (in code but not in config):") for entry in missing: - console.info(f" - {entry}") + console.warn(f" - {entry}") - if not errors and not warnings and not missing: - console.log(f"{MINIFY_JSON} is valid and up-to-date.") - elif errors: + if errors: raise SystemExit(1) + console.log(f"{MINIFY_JSON} is valid and up-to-date.") @minify.command(name="list") From f964c7aa78b41cfcba64c9c0f4a2e0d051f99aa1 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 7 Feb 2026 21:47:51 +0100 Subject: [PATCH 28/28] fix: use get_event_handler in upload --- reflex/app.py | 21 ++++++++------------- reflex/state.py | 17 ++++++++--------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index 4ff412ef863..6245a9f0d1d 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -77,13 +77,13 @@ from reflex.event import ( _EVENT_FIELDS, Event, - EventHandler, EventSpec, EventType, IndividualEventType, get_hydrate_event, noop, ) +from reflex.istate.proxy import StateProxy from reflex.page import DECORATED_PAGES from reflex.route import ( get_route_args, @@ -1619,6 +1619,8 @@ def _process_background( if not handler.is_background: return None + substate = StateProxy(substate) + async def _coro(): """Coroutine to process the event and emit updates inside an asyncio.Task. @@ -1934,21 +1936,14 @@ async def upload_file(request: Request): substate_token = _substate_key(token, handler.rpartition(".")[0]) state = await app.state_manager.get_state(substate_token) - # get the current session ID - # get the current state(parent state/substate) - path = handler.split(".")[:-1] - current_state = state.get_substate(path) handler_upload_param = () - # get handler function - func = getattr(type(current_state), handler.split(".")[-1]) + _current_state, event_handler = state._get_event_handler(handler) - # check if there exists any handler args with annotation, list[UploadFile] - if isinstance(func, EventHandler): - if func.is_background: - msg = f"@rx.event(background=True) is not supported for upload handler `{handler}`." - raise UploadTypeError(msg) - func = func.fn + if event_handler.is_background: + msg = f"@rx.event(background=True) is not supported for upload handler `{handler}`." + raise UploadTypeError(msg) + func = event_handler.fn if isinstance(func, functools.partial): func = func.func for k, v in get_type_hints(func).items(): diff --git a/reflex/state.py b/reflex/state.py index e3b96dec216..22c420ca49a 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1795,13 +1795,11 @@ def _get_original_event_name(cls, minified_name: str) -> str | None: # Direct lookup: _event_id_to_name maps minified_name -> original_name return cls._event_id_to_name.get(minified_name) - def _get_event_handler( - self, event: Event - ) -> tuple[BaseState | StateProxy, EventHandler]: + def _get_event_handler(self, event: Event | str) -> tuple[BaseState, EventHandler]: """Get the event handler for the given event. Args: - event: The event to get the handler for. + event: The event to get the handler for, or a dotted handler name string. Returns: @@ -1811,7 +1809,8 @@ def _get_event_handler( ValueError: If the event handler or substate is not found. """ # Get the event handler. - path = event.name.split(".") + name = event.name if isinstance(event, Event) else event + path = name.split(".") path, name = path[:-1], path[-1] substate = self.get_substate(path) if not substate: @@ -1829,10 +1828,6 @@ def _get_event_handler( msg = f"Event handler '{name}' not found in state '{type(substate).__name__}'" raise KeyError(msg) - # For background tasks, proxy the state - if handler.is_background: - substate = StateProxy(substate) - return substate, handler async def _process(self, event: Event) -> AsyncIterator[StateUpdate]: @@ -1847,6 +1842,10 @@ async def _process(self, event: Event) -> AsyncIterator[StateUpdate]: # Get the event handler. substate, handler = self._get_event_handler(event) + # For background tasks, proxy the state. + if handler.is_background: + substate = StateProxy(substate) + # Run the event generator and yield state updates. async for update in self._process_event( handler=handler,