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/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", diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..1affca62073 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -16,7 +16,9 @@ import { initialState, onLoadInternalEvent, state_name, - exception_state_name, + handle_frontend_exception, + 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)); }; /** @@ -967,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: "", }), @@ -979,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 + ": " + @@ -1044,10 +1046,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(update_vars_internal, { + vars: vars, + }); addEvents([event], e); } }; @@ -1082,7 +1083,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/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/compiler/templates.py b/reflex/compiler/templates.py index 2abcb6dd533..452f09a4b46 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: @@ -274,6 +274,24 @@ 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 = format_event_handler(OnLoadInternalState.on_load_internal) + update_vars_internal = format_event_handler( + UpdateVarsInternalState.update_vars_internal + ) + handle_frontend_exception = format_event_handler( + FrontendEventExceptionState.handle_frontend_exception + ) + initial_state = initial_state or {} state_contexts_str = "".join([ f"{format_state_name(state_name)}: createContext(null)," @@ -284,7 +302,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 handle_frontend_exception = "{handle_frontend_exception}" // These events are triggered on initial load and each page navigation. export const onLoadInternalEvent = () => {{ @@ -296,7 +318,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}', + '{update_vars_internal}', {{vars: client_storage_vars}}, ), ); @@ -304,7 +326,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('{on_load_internal}')); return internal_events; }} @@ -319,7 +341,11 @@ def context_template( else """ export const state_name = undefined -export const exception_state_name = undefined +export const main_state_name = undefined + +export const update_vars_internal = undefined + +export const handle_frontend_exception = undefined export const onLoadInternalEvent = () => [] diff --git a/reflex/components/recharts/cartesian.py b/reflex/components/recharts/cartesian.py index c17f135506e..d543ee555f8 100644 --- a/reflex/components/recharts/cartesian.py +++ b/reflex/components/recharts/cartesian.py @@ -242,10 +242,8 @@ class Brush(Recharts): end_index: Var[int] # The fill color of brush - fill: Var[str | Color] # The stroke color of brush - stroke: Var[str | Color] @classmethod def get_event_triggers(cls) -> dict[str, Var | Any]: 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/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/environment.py b/reflex/environment.py index 279fc5f60c1..1b074b47b26 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_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) + environment = EnvironmentVariables() diff --git a/reflex/minify.py b/reflex/minify.py new file mode 100644 index 00000000000..7794d6198c3 --- /dev/null +++ b/reflex/minify.py @@ -0,0 +1,589 @@ +"""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 any minification is enabled (state or event). + + Returns: + True if either state or event minification is enabled. + """ + 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_STATES.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: + """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() + is_state_minify_enabled.cache_clear() + is_event_minify_enabled.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 cb677c3173e..f54ccbdce2e 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -842,6 +842,411 @@ def rename(new_name: str): rename_app(new_name, get_config().loglevel) +# 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 = sum(len(handlers) for handlers in config["events"].values()) + 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 = sum(len(handlers) for handlers in existing_config["events"].values()) + + # 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 = sum(len(handlers) for handlers in new_config["events"].values()) + + 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.warn(f" - {entry}") + + if errors: + raise SystemExit(1) + console.log(f"{MINIFY_JSON} is valid and up-to-date.") + + +@minify.command(name="list") +@loglevel_option +@click.option( + "--json", + "output_json", + is_flag=True, + help="Output as JSON.", +) +def minify_list(output_json: bool): + """Print the state tree with IDs and minified names.""" + from typing import TypedDict + + from reflex.minify import ( + get_event_id, + get_minify_config, + get_state_full_path, + get_state_id, + ) + 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: str | None # The minified name (e.g., "a", "ba") or None + + class StateTreeData(TypedDict): + """Type for state tree data.""" + + name: str + 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() + + # 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. + + Args: + state_cls: The state class to build the tree for. + + Returns: + A dictionary containing the state tree data. + """ + 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 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": handler_name, + "event_id": event_id, + }) + + # 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_path": state_path, + "state_id": state_id, + "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 is now the minified name directly (e.g., "a", "ba") + state_id = state_data["state_id"] + + # 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}"') + else: + console.log(f"{prefix}{connector}{state_data['name']}") + + # 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 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}"' + ) + else: + console.log(f"{handler_prefix}{h_connector}{handler['name']}") + + # 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: + if minify_enabled: + console.log("State Tree (minify.json loaded)") + else: + console.log("State Tree (no minify.json)") + print_state_tree(tree_data) + + +@minify.command(name="lookup") +@loglevel_option +@click.option( + "--json", + "output_json", + is_flag=True, + help="Output detailed info as JSON.", +) +@click.argument("minified_path") +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 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() + + 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. + + 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 + + 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": found.__module__, + "class": found.__name__, + "full_path": state_path, + }) + current = found + + if output_json: + import json + + click.echo(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']}") + + def _convert_reflex_loglevel_to_reflex_cli_loglevel( loglevel: constants.LogLevel, ) -> HostingLogLevel: diff --git a/reflex/state.py b/reflex/state.py index 89698cfa186..22c420ca49a 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -392,6 +392,11 @@ 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() + # Per-class registry mapping event_id -> event handler name for minification. + # 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) @@ -523,6 +528,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): super().__init_subclass__(**kwargs) + # Mixin states are not initialized if cls._mixin: return @@ -603,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(): @@ -645,6 +652,10 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): cls.event_handlers[name] = handler setattr(cls, name, handler) + # 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 = {} cls._init_var_dependency_dicts() @@ -656,16 +667,46 @@ 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_state_full_path, + is_event_minify_enabled, + ) + + if not is_event_minify_enabled(): + 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: @@ -991,10 +1032,25 @@ def get_name(cls) -> str: """Get the name of the state. Returns: - The name of the state. + The name of the state (minified if configured in minify.json). """ + from reflex.minify import ( + get_state_full_path, + get_state_id, + is_state_minify_enabled, + ) + module = cls.__module__.replace(".", "___") - return format.to_snake_case(f"{module}___{cls.__name__}") + full_name = format.to_snake_case(f"{module}___{cls.__name__}") + + # 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: + return state_id + + return full_name @classmethod @functools.lru_cache @@ -1012,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. @@ -1029,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) @@ -1176,7 +1238,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): @@ -1216,6 +1281,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): @@ -1545,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. @@ -1559,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]]: @@ -1709,13 +1779,27 @@ async def get_var_value(self, var: Var[VAR_TYPE]) -> VAR_TYPE: ) return getattr(other_state, var_data.field_name) - def _get_event_handler( - self, event: Event - ) -> tuple[BaseState | StateProxy, EventHandler]: + @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. + """ + # 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 | 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: @@ -1725,17 +1809,24 @@ 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: msg = "The value of state cannot be None when processing an event." raise ValueError(msg) - handler = substate.event_handlers[name] - # For background tasks, proxy the state - if handler.is_background: - substate = StateProxy(substate) + # 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) return substate, handler @@ -1751,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, diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 3093e455d55..64289a7c65d 100644 --- a/reflex/utils/format.py +++ b/reflex/utils/format.py @@ -6,6 +6,7 @@ import json import os import re +from collections.abc import Callable from typing import TYPE_CHECKING, Any from reflex import constants @@ -439,15 +440,25 @@ 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: handler: The event handler to get the parts of. Returns: - The state and function name. + The state and function name (possibly minified based on minify.json). """ + from reflex.event import EventHandler + from reflex.minify import is_event_minify_enabled + from reflex.state import State + + assert isinstance(handler, EventHandler), ( + f"Expected EventHandler, got {type(handler)}" + ) + # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") @@ -461,15 +472,29 @@ 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 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_event_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) -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/integration/test_minification.py b/tests/integration/test_minification.py new file mode 100644 index 00000000000..ad2e94e5684 --- /dev/null +++ b/tests/integration/test_minification.py @@ -0,0 +1,396 @@ +"""Integration tests for state and event handler minification.""" + +from __future__ import annotations + +import json +from collections.abc import Generator +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from selenium.webdriver.remote.webdriver import WebDriver + + +def MinificationApp(): + """Test app for state and event handler minification. + + 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): + """Root state for testing.""" + + count: int = 0 + + @rx.event + def increment(self): + """Increment the count.""" + self.count += 1 + + class SubState(RootState): + """Sub state for testing.""" + + 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}" + + # 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 +def minify_disabled_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start app WITHOUT minify.json (full names). + + Args: + app_harness_env: AppHarness or AppHarnessProd + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + Running AppHarness instance + """ + # 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=MinificationApp, + ) as harness: + yield harness + + +@pytest.fixture +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 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() + + 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=MinificationApp, + ) + + # 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 +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 without minify.json, full state and event names are used. + + 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 with minify.json, minified state and event names are used. + + 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 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 + + # 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 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.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_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 + 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 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 new file mode 100644 index 00000000000..a941d175bc8 --- /dev/null +++ b/tests/units/test_minification.py @@ -0,0 +1,1022 @@ +"""Unit tests for state and event handler minification via minify.json.""" + +from __future__ import annotations + +import json + +import pytest + +from reflex.environment import MinifyMode, environment +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_event_minify_enabled, + is_minify_enabled, + is_state_minify_enabled, + minified_name_to_int, + save_minify_config, + sync_minify_config, + validate_minify_config, +) +from reflex.state import BaseState, State + + +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) + + def test_negative_raises(self): + """Test that negative IDs raise ValueError.""" + with pytest.raises(ValueError, match="non-negative"): + int_to_minified_name(-1) + + +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("!") + + +class TestGetStateFullPath: + """Tests for get_state_full_path function.""" + + 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" + + def test_substate_path(self): + """Test that substates have correct full paths.""" + + class TestState(BaseState): + pass + + path = get_state_full_path(TestState) + assert "TestState" in path + assert path.startswith("tests.units.test_minification.") + + +@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, monkeypatch): + """Test saving and loading a config.""" + 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"}, + "events": {"test.module.MyState": {"handler": "a"}}, + } + save_minify_config(config) + + # Clear cache and reload + clear_config_cache() + + assert is_minify_enabled() is True + 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, monkeypatch): + """Test that invalid version raises ValueError.""" + 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: + json.dump(config, f) + + clear_config_cache() + + with pytest.raises(ValueError, match=r"Unsupported.*version"): + is_state_minify_enabled() + + def test_missing_states_raises(self, temp_minify_json, monkeypatch): + """Test that missing 'states' key raises ValueError.""" + 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: + json.dump(config, f) + + clear_config_cache() + + with pytest.raises(ValueError, match="'states' must be"): + is_state_minify_enabled() + + +class TestGenerateMinifyConfig: + """Tests for generate_minify_config function.""" + + def test_generate_for_root_state(self): + """Test generating config for the root State.""" + config = generate_minify_config(State) + + 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_generates_unique_sibling_ids(self): + """Test that sibling states get unique IDs.""" + + class ParentState(BaseState): + pass + + class ChildA(ParentState): + pass + + class ChildB(ParentState): + pass + + config = generate_minify_config(ParentState) + + # 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) + + assert child_a_id is not None + assert child_b_id is not None + assert child_a_id != child_b_id + + +class TestValidateMinifyConfig: + """Tests for validate_minify_config function.""" + + 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) + + assert len(errors) == 0 + assert len(missing) == 0 + + 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 + + errors, _warnings, _missing = validate_minify_config(config, Parent) + + assert any("Duplicate state_id='b'" in e for e in errors) + + +class TestSyncMinifyConfig: + """Tests for sync_minify_config function.""" + + def test_sync_adds_new_states(self): + """Test that sync adds new states.""" + + class TestState(BaseState): + def handler(self): + pass + + # Start with empty config + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": {}, + } + + new_config = sync_minify_config(existing_config, TestState) + + # 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_sync_preserves_existing_ids(self): + """Test that sync preserves existing IDs.""" + + class TestState(BaseState): + def handler_a(self): + pass + + def handler_b(self): + pass + + state_path = get_state_full_path(TestState) + + # Start with partial config (using string IDs in v2 format) + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "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" # 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] + assert ( + new_config["events"][state_path]["handler_b"] == "l" + ) # 10 + 1 = 11 -> 'l' + + +class TestStateMinification: + """Tests for state name minification with minify.json.""" + + 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): + pass + + TestState.get_name.cache_clear() + name = TestState.get_name() + + # 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, monkeypatch): + """Test that states use minified names when minify.json exists and env var is enabled.""" + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + + 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() + + name = TestState.get_name() + + # 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( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.DISABLED.value + ) + + 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.""" + + 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 + + class TestState(BaseState): + @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_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( + 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__ + # 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 + }, + } + save_minify_config(config) + clear_config_cache() + State.get_name.cache_clear() + 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 my_handler(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}" + ) + + # The state's _event_id_to_name should be populated (key is minified name) + assert TestStateWithMinifiedEvent._event_id_to_name == {"d": "my_handler"} + + handler = TestStateWithMinifiedEvent.event_handlers["my_handler"] + _, event_name = get_event_handler_parts(handler) + + # 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( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.DISABLED.value + ) + + 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, monkeypatch): + """Test that setvar is registered in _event_id_to_name when config exists.""" + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + 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, monkeypatch): + """Test that auto-setters (set_*) are registered in _event_id_to_name when config exists.""" + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + 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, monkeypatch + ): + """Test that dynamically added event handlers via _add_event_handler are registered.""" + import reflex as rx + + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + 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" + + +class TestMinifyModeEnvVars: + """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.""" + 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( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + 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( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + 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( + 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, 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, MinifyMode.ENABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.DISABLED.value + ) + 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( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.DISABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + 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 + + +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( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + + # 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( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + + 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 + + +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" diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 5802cfa8b6f..e3f5d5c026d 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",