diff --git a/.gitignore b/.gitignore index 508d57ca9d..c4c0a15927 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/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index e45857357b..ff75874d90 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"; @@ -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 2abcb6dd53..7eccca3e6e 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 873cce69a1..4eefafef41 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/environment.py b/reflex/environment.py index 279fc5f60c..87fcf4484d 100644 --- a/reflex/environment.py +++ b/reflex/environment.py @@ -486,6 +486,15 @@ class PerformanceMode(enum.Enum): OFF = "off" +@enum.unique +class MinifyMode(enum.Enum): + """Mode for state/event 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 + + class ExecutorType(enum.Enum): """Executor for compiling the frontend.""" @@ -688,6 +697,12 @@ 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[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 748fcd2780..e0c7367bfd 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/reflex.py b/reflex/reflex.py index cb677c3173..f321cc59d7 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,229 @@ 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 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]) -> StateTreeData: + """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: StateTreeData, 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"] + + # 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}")' + ) + else: + console.log(f"{prefix}{connector}{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: + 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: + 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): + 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: + import json + + console.log(json.dumps(tree_data, indent=2)) + else: + console.log("State Tree") + 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: + 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({ + "minified": part, + "state_id": state_id, + "module": state_cls.__module__, + "class": state_cls.__name__, + "full_name": state_cls.get_full_name(), + }) + + if output_json: + import json + + console.log(json.dumps(result_parts, indent=2)) + else: + # Simple output: module.ClassName for each part + for info in result_parts: + console.log(f"{info['module']}.{info['class']}") + + +@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: + console.log("0") + 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 + + 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 572e67f67c..66e939a844 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, @@ -102,6 +103,67 @@ # 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$_" + + +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. @@ -391,6 +453,12 @@ 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]] = {} + # The parent state. parent_state: BaseState | None = field(default=None, is_var=False) @@ -508,23 +576,52 @@ 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) + # 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) + 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() @@ -644,6 +741,36 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): 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() @@ -686,6 +813,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 @@ -988,9 +1119,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 MinifyMode + 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 == 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 == 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)" + ) + raise StateValueError(msg) + + # ENABLED mode with no state_id - use full name + return full_name @classmethod @functools.lru_cache @@ -1705,6 +1861,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]: @@ -1727,7 +1902,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: @@ -2464,7 +2649,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. @@ -2557,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. @@ -2610,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: @@ -2634,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/reflex/utils/format.py b/reflex/utils/format.py index 3093e455d5..6e09404910 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/reflex/vars/base.py b/reflex/vars/base.py index 8a8f0e754d..549b4896d5 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_minification.py b/tests/integration/test_minification.py new file mode 100644 index 0000000000..b60c7f1626 --- /dev/null +++ b/tests/integration/test_minification.py @@ -0,0 +1,391 @@ +"""Integration tests for state and event handler 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 MinifyMode, 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 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(event_id=increment_event_id) + 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(event_id=update_message_event_id) + 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}" + + # 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( + 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( + 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"), + 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" + 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("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) + os.environ.pop("REFLEX_MINIFY_EVENTS", None) + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.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. + + 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" + 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("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) + os.environ.pop("REFLEX_MINIFY_EVENTS", None) + environment.REFLEX_MINIFY_STATES.set(MinifyMode.DISABLED) + environment.REFLEX_MINIFY_EVENTS.set(MinifyMode.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_minification_disabled( + minify_disabled_app: AppHarness, + driver_disabled: WebDriver, +) -> None: + """Test that DISABLED mode uses full state and event 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}" + + # 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" + + 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_minification_enabled( + minify_enabled_app: AppHarness, + driver_enabled: WebDriver, +) -> None: + """Test that ENABLED mode uses minified state and event 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 + + # 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" + + 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 with minified names + 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_app.py b/tests/units/test_app.py index 6efd006f1f..780e57c7d4 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 new file mode 100644 index 0000000000..2dca8700f9 --- /dev/null +++ b/tests/units/test_minification.py @@ -0,0 +1,523 @@ +"""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, + FrontendEventExceptionState, + OnLoadInternalState, + State, + 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.""" + 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 + + +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 ca41ac37ab..ef4a80b30a 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",