diff --git a/.gitignore b/.gitignore index 533bcfcec8e..62dd0c954f6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ node_modules package-lock.json *.pyi .pre-commit-config.yaml +uploaded_files/* .claude/.worktrees .claude/settings.local.json CLAUDE.local.md diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 05af3acc362..75e17d831d8 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -15,7 +15,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"; @@ -142,7 +144,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)); }; /** @@ -1007,7 +1009,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: "", }), @@ -1019,7 +1021,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 + ": " + @@ -1084,10 +1086,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); } }; @@ -1122,7 +1123,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/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index ca94aa2c482..01cc970bfb4 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -10,7 +10,7 @@ from reflex_base import constants from reflex_base.constants import Hooks from reflex_base.utils import memo_paths -from reflex_base.utils.format import format_state_name, json_dumps +from reflex_base.utils.format import format_event_handler, format_state_name, json_dumps from reflex_base.vars.base import VarData if TYPE_CHECKING: @@ -289,6 +289,23 @@ 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, + ) + + 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)," @@ -299,7 +316,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 = () => {{ @@ -311,7 +332,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}}, ), ); @@ -319,7 +340,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; }} @@ -334,7 +355,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/packages/reflex-base/src/reflex_base/constants/compiler.py b/packages/reflex-base/src/reflex_base/constants/compiler.py index 036dfe1799a..687fb8c583b 100644 --- a/packages/reflex-base/src/reflex_base/constants/compiler.py +++ b/packages/reflex-base/src/reflex_base/constants/compiler.py @@ -67,18 +67,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/packages/reflex-base/src/reflex_base/constants/event.py b/packages/reflex-base/src/reflex_base/constants/event.py index 515b34574e9..aa18007602f 100644 --- a/packages/reflex-base/src/reflex_base/constants/event.py +++ b/packages/reflex-base/src/reflex_base/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/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 33461cc19fc..24f73fcd7db 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -549,6 +549,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.""" @@ -741,6 +748,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) + # Extra plugins to append to the config's plugins list. REFLEX_EXTRA_PLUGINS: EnvVar[list[type[Plugin]]] = env_var([]) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 919e1f2fd1f..f5c25556e86 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -651,6 +651,26 @@ def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": ) +if TYPE_CHECKING: + + def typing_event(fn: Callable[..., Any]) -> EventHandler: + """Mark ``fn`` so pyright sees it as the ``EventHandler`` that + ``BaseState.__init_subclass__`` will rewrite it into. No-op at runtime. + + Args: + fn: The method to mark. + + Returns: + ``fn`` typed as :class:`EventHandler`. + """ + ... + +else: + + def typing_event(fn: Callable[..., Any]) -> Callable[..., Any]: # noqa: D103 + return fn + + @dataclasses.dataclass( init=True, frozen=True, @@ -3110,6 +3130,7 @@ def BaseState(self) -> "type[BaseState]": # noqa: N802 event = EventNamespace event.event = event # pyright: ignore[reportAttributeAccessIssue] +event.typing_event = staticmethod(typing_event) # pyright: ignore[reportAttributeAccessIssue] _this = sys.modules[__name__] event.__path__ = _this.__path__ # pyright: ignore[reportAttributeAccessIssue] event.__spec__ = _this.__spec__ # pyright: ignore[reportAttributeAccessIssue] diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 71b4d723e5e..8c146ba1341 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -1,9 +1,16 @@ -"""A contextual registry for state and event handlers.""" +"""Contextual registry for state classes, event handlers, and the +:class:`NameResolver` strategy that turns them into user-visible names. + +The default resolver is a no-op; ``reflex.minify.MinifyNameResolver`` +plugs in a :class:`minify.json`-driven implementation. Install a custom +resolver via :meth:`RegistrationContext.set_name_resolver`. +""" from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable from typing_extensions import Self @@ -14,6 +21,71 @@ from reflex.state import BaseState from reflex_base.event import EventHandler +_T = TypeVar("_T") + + +def _rekey( + items: Iterable[_T], key_fn: Callable[[_T], str], kind: str +) -> dict[str, _T]: + """Build a name-keyed dict, warning on collisions. + + Args: + items: Source items to re-key. + key_fn: Computes the new key for each item. + kind: Human-readable noun for the collision warning (e.g. ``"state class"``). + + Returns: + A new dict mapping resolved key to item; later items overwrite earlier. + """ + from reflex.utils import console + + out: dict[str, _T] = {} + for item in items: + key = key_fn(item) + existing = out.get(key) + if existing is not None and existing is not item: + console.warn( + f"Two {kind}s resolve to the same full name {key!r}: " + f"{existing!r} and {item!r}. The first one will be unreachable " + "in the registry. Check minify.json for duplicate ids." + ) + out[key] = item + return out + + +@runtime_checkable +class NameResolver(Protocol): + """Resolves user-visible names for state classes and event handlers. + + Return ``None`` to defer to the framework default. See + :class:`DefaultNameResolver` (no-op) and ``reflex.minify.MinifyNameResolver``. + """ + + def resolve_state_name(self, state_cls: type[BaseState]) -> str | None: + """Return the resolved name for ``state_cls``, or ``None`` for default.""" + ... + + def resolve_handler_name( + self, state_cls: type[BaseState], handler_name: str + ) -> str | None: + """Return the resolved name for the handler, or ``None`` for default.""" + ... + + +@dataclasses.dataclass(frozen=True, slots=True) +class DefaultNameResolver: + """No-op resolver — every state and handler keeps its default name.""" + + def resolve_state_name(self, state_cls: type[BaseState]) -> str | None: # noqa: D102 + return None + + def resolve_handler_name( # noqa: D102 + self, + state_cls: type[BaseState], + handler_name: str, + ) -> str | None: + return None + @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) class RegisteredEventHandler: @@ -39,6 +111,10 @@ class RegistrationContext(BaseContext): default_factory=dict, repr=False, ) + name_resolver: NameResolver = dataclasses.field( + default_factory=DefaultNameResolver, + repr=False, + ) @classmethod def ensure_context(cls) -> Self: @@ -55,6 +131,18 @@ def ensure_context(cls) -> Self: cls._context_var.set(ctx) return ctx + @classmethod + def try_get(cls) -> Self | None: + """Return the active context, or ``None`` when none is attached. + + Returns: + The registration context instance, or ``None``. + """ + try: + return cls.get() + except LookupError: + return None + @classmethod def register_base_state(cls, state_cls: type[BaseState]) -> type[BaseState]: """Register a base state class with its full name. @@ -153,3 +241,102 @@ def get_substates( return self.base_state_substates.setdefault( base_state_cls.get_full_name(), set() ) + + @staticmethod + def default_state_name(state_cls: type[BaseState]) -> str: + """Compute the built-in snake-cased ``module___ClassName`` for a state. + + Args: + state_cls: The state class. + + Returns: + The default name. + """ + from reflex.utils import format + + module = state_cls.__module__.replace(".", "___") + return format.to_snake_case(f"{module}___{state_cls.__name__}") + + def get_state_name(self, state_cls: type[BaseState]) -> str: + """Resolve the user-visible name for a state class. + + Args: + state_cls: The state class. + + Returns: + The resolved name (or :meth:`default_state_name` fallback). + """ + resolved = self.name_resolver.resolve_state_name(state_cls) + if resolved is not None: + return resolved + return self.default_state_name(state_cls) + + def get_handler_name(self, state_cls: type[BaseState], handler_name: str) -> str: + """Resolve the user-visible name for an event handler. + + Args: + state_cls: The state class the handler is attached to. + handler_name: The original (Python) name of the handler. + + Returns: + The resolved name (or ``handler_name`` unchanged). + """ + resolved = self.name_resolver.resolve_handler_name(state_cls, handler_name) + if resolved is not None: + return resolved + return handler_name + + def set_name_resolver(self, resolver: NameResolver) -> None: + """Install ``resolver`` and rebuild the registry under the new names. + + Clears the per-class ``get_name`` / ``get_full_name`` / + ``get_class_substate`` lru_caches and calls :meth:`refresh_keys`. + Uses ``object.__setattr__`` to mutate the frozen ``name_resolver`` + slot. + + Args: + resolver: The resolver to install. Pass :class:`DefaultNameResolver` + to revert to built-in names. + """ + from reflex_base.utils.format import _FORMATTED_NAME_CACHE_ATTR + + object.__setattr__(self, "name_resolver", resolver) + for cls in self.base_states.values(): + cls.get_name.cache_clear() + cls.get_full_name.cache_clear() + cls.get_class_substate.cache_clear() + for reg in self.event_handlers.values(): + reg.handler.__dict__.pop(_FORMATTED_NAME_CACHE_ATTR, None) + self.refresh_keys() + + def refresh_keys(self) -> None: + """Re-key the name-keyed dicts using current ``get_full_name`` values. + + Built atomically: the replacement dicts are populated before any of + ``self`` is mutated, so a partway failure (e.g. malformed minify.json + making ``format_event_handler`` raise) leaves the existing registry + intact. A console warning is emitted on full-name collisions — + usually a sign of duplicate ids in ``minify.json``. + """ + from reflex.utils.format import format_event_handler + + new_base_states = _rekey( + self.base_states.values(), lambda c: c.get_full_name(), "state class" + ) + new_substates: dict[str, set[type[BaseState]]] = {} + for cls in new_base_states.values(): + parent = cls.get_parent_state() + if parent is not None: + new_substates.setdefault(parent.get_full_name(), set()).add(cls) + new_handlers = _rekey( + self.event_handlers.values(), + lambda r: format_event_handler(r.handler), + "event handler", + ) + + self.base_states.clear() + self.base_states.update(new_base_states) + self.base_state_substates.clear() + self.base_state_substates.update(new_substates) + self.event_handlers.clear() + self.event_handlers.update(new_handlers) diff --git a/packages/reflex-base/src/reflex_base/utils/format.py b/packages/reflex-base/src/reflex_base/utils/format.py index 35a2bc3e30a..9e98e5f8345 100644 --- a/packages/reflex-base/src/reflex_base/utils/format.py +++ b/packages/reflex-base/src/reflex_base/utils/format.py @@ -442,43 +442,55 @@ def format_props(*single_props, **key_value_props) -> list[str]: def get_event_handler_parts(handler: EventHandler) -> tuple[str, str]: - """Get the state and function name of an event handler. + """Get the (state, function) name pair for an event handler. + + Both names pass through the active + :class:`~reflex_base.registry.NameResolver`, so any installed rewrite + (minification, prefixing, etc.) is applied transparently. Args: - handler: The event handler to get the parts of. + handler: The event handler. Returns: - The state and function name. + ``(state_full_name, handler_name)`` — both resolved. """ - # Get the name of the event function. - name = handler.fn.__qualname__ - - # Get the state full name - state_full_name = handler.state.get_full_name() if handler.state else "" + from reflex_base.registry import DefaultNameResolver, RegistrationContext - # If there's no enclosing state, just return the full name. + name = handler.fn.__qualname__ if handler.state is None: return ("", name) - # Get the event name inside the state. + state_full_name = handler.state.get_full_name() func_name = name.rpartition(".")[2] + ctx = RegistrationContext.try_get() + if ctx is None or type(ctx.name_resolver) is DefaultNameResolver: + return (state_full_name, func_name) + return (state_full_name, ctx.get_handler_name(handler.state, func_name)) + - return (state_full_name, func_name) +_FORMATTED_NAME_CACHE_ATTR = "_formatted_name" def format_event_handler(handler: EventHandler) -> str: """Format an event handler. + Cached on the handler instance under ``_formatted_name`` to skip the + registry/resolver dispatch on every event. The cache is invalidated by + :meth:`reflex_base.registry.RegistrationContext.set_name_resolver`. + Args: handler: The event handler to format. Returns: The formatted function. """ + cached = handler.__dict__.get(_FORMATTED_NAME_CACHE_ATTR) + if cached is not None: + return cached state, name = get_event_handler_parts(handler) - if state == "": - return name - return f"{state}.{name}" + full = name if state == "" else f"{state}.{name}" + object.__setattr__(handler, _FORMATTED_NAME_CACHE_ATTR, full) + return full def format_event(event_spec: EventSpec) -> str: diff --git a/reflex/minify.py b/reflex/minify.py new file mode 100644 index 00000000000..87db86aaef0 --- /dev/null +++ b/reflex/minify.py @@ -0,0 +1,734 @@ +"""State and event name minification driven by ``minify.json``. + +The minification entry point is :class:`MinifyNameResolver`, a +:class:`reflex_base.registry.NameResolver` implementation. Install it via +:func:`install_minify_resolver` (called automatically by +:func:`reflex.utils.prerequisites.get_compiled_app` and +:func:`clear_config_cache`). +""" + +from __future__ import annotations + +import dataclasses +import functools +import json +from collections.abc import Iterable +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 StateEntry(TypedDict): + """A single state entry in ``minify.json``. + + ``parent`` preserves the sibling scope of an entry even after the class is + renamed or deleted, keeping orphaned ids reserved in their sibling group. + """ + + id: str # minified name, e.g. "a" + parent: str | None # parent state_path, None for root states + + +class MinifyConfig(TypedDict): + """Schema for ``minify.json`` (version :data:`SCHEMA_VERSION`).""" + + version: int + states: dict[str, StateEntry] # state_path -> {id, parent} + events: dict[str, dict[str, str]] # state_path -> {handler_name -> minified_name} + + +def _get_minify_json_path() -> Path: + """Return the path to ``minify.json`` in the current working directory.""" + return Path.cwd() / MINIFY_JSON + + +def _load_minify_config_uncached() -> MinifyConfig | None: + """Load and validate ``minify.json`` from disk. + + Returns: + The parsed config, or ``None`` if the file is absent. + + Raises: + ValueError: If the file exists but is malformed. + """ + 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 {id: str, parent: str | None} entries + for key, value in data["states"].items(): + if not isinstance(value, dict) or not isinstance(value.get("id"), str): + msg = f"Invalid {MINIFY_JSON}: state '{key}' must be an object with a string 'id': {value}" + raise ValueError(msg) + parent = value.get("parent") + if parent is not None and not isinstance(parent, str): + msg = ( + f"Invalid {MINIFY_JSON}: state '{key}' has non-string parent: {parent}" + ) + 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: + """Read ``minify.json`` once per process. + + Returns: + The parsed config, or ``None`` if the file is absent. + """ + return _load_minify_config_uncached() + + +@functools.cache +def is_mode_enabled(env_var_name: str) -> bool: + """Whether the given ``REFLEX_MINIFY_*`` env var is on and a config exists. + + Args: + env_var_name: The env-var attribute name on + :class:`~reflex.environment.EnvironmentVariables`. + + Returns: + ``True`` if the env var is ``ENABLED`` and ``minify.json`` exists. + """ + from reflex.environment import MinifyMode, environment + + env_var = getattr(environment, env_var_name) + return env_var.get() == MinifyMode.ENABLED and get_minify_config() is not None + + +def is_minify_enabled() -> bool: + """Whether either state or event minification is enabled. + + Returns: + ``True`` when ``REFLEX_MINIFY_STATES`` or ``REFLEX_MINIFY_EVENTS`` is + on and ``minify.json`` exists. + """ + return is_mode_enabled("REFLEX_MINIFY_STATES") or is_mode_enabled( + "REFLEX_MINIFY_EVENTS" + ) + + +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") + + +@dataclasses.dataclass(slots=True) +class MinifyNameResolver: + """:class:`~reflex_base.registry.NameResolver` driven by ``minify.json``. + + Returns the minified name when the matching env-var + (``REFLEX_MINIFY_STATES`` / ``REFLEX_MINIFY_EVENTS``) is enabled and the + entry exists in the config; ``None`` otherwise. Per-class lookups are + memoized for O(1) amortized cost. + + Attributes: + config: Parsed ``minify.json``, or ``None``. + states_enabled: Whether ``REFLEX_MINIFY_STATES`` is on. + events_enabled: Whether ``REFLEX_MINIFY_EVENTS`` is on. + """ + + config: MinifyConfig | None + states_enabled: bool + events_enabled: bool + _state_cache: dict[type[BaseState], str] = dataclasses.field( + default_factory=dict, repr=False + ) + _event_cache: dict[type[BaseState], dict[str, str]] = dataclasses.field( + default_factory=dict, repr=False + ) + + @classmethod + def from_disk(cls) -> MinifyNameResolver: + """Build a resolver from ``minify.json`` (uncached) and env vars. + + Malformed configs degrade gracefully to ``config=None`` with a warning. + + Returns: + A configured resolver. + """ + from reflex.environment import MinifyMode, environment + from reflex.utils import console + + try: + config = _load_minify_config_uncached() + except ValueError as e: + console.warn( + f"{MINIFY_JSON} could not be loaded: {e}; minification disabled." + ) + config = None + return cls( + config=config, + states_enabled=environment.REFLEX_MINIFY_STATES.get() == MinifyMode.ENABLED, + events_enabled=environment.REFLEX_MINIFY_EVENTS.get() == MinifyMode.ENABLED, + ) + + def _is_minify_allowed(self, state_cls: type[BaseState], enabled: bool) -> bool: + """Whether ``state_cls`` is eligible for minification under the given mode. + + Args: + state_cls: The state class being resolved. + enabled: Whether the relevant ``REFLEX_MINIFY_*`` env var is on. + + Returns: + ``True`` when the env var is on and ``state_cls`` is user-defined. + """ + return enabled and not _is_framework_state(state_cls) + + def resolve_state_name(self, state_cls: type[BaseState]) -> str | None: # noqa: D102 + if self.config is None or not self._is_minify_allowed( + state_cls, self.states_enabled + ): + return None + cached = self._state_cache.get(state_cls) + if cached is not None: + return cached + entry = self.config["states"].get(get_state_full_path(state_cls)) + if entry is None: + return None + resolved = entry["id"] + self._state_cache[state_cls] = resolved + return resolved + + def resolve_handler_name( # noqa: D102 + self, state_cls: type[BaseState], handler_name: str + ) -> str | None: + if self.config is None or not self._is_minify_allowed( + state_cls, self.events_enabled + ): + return None + per_state = self._event_cache.get(state_cls) + if per_state is None: + per_state = self.config["events"].get(get_state_full_path(state_cls), {}) + self._event_cache[state_cls] = per_state + return per_state.get(handler_name) + + +#: Modules whose ``BaseState`` subclasses can never be minified — their +#: :class:`Var` hooks are baked at framework-import time before any user +#: resolver can run. ``reflex.istate.dynamic`` is *not* listed: that's where +#: ``ComponentState.create()`` puts user-owned dynamic classes. +_FRAMEWORK_STATE_MODULES: frozenset[str] = frozenset({ + "reflex.state", + "reflex.istate.shared", + "reflex.custom_components.custom_components", +}) + + +def _is_framework_state(state_cls: type[BaseState]) -> bool: + """Whether ``state_cls`` is one of the framework's own state classes. + + Args: + state_cls: The state class to check. + + Returns: + ``True`` if ``state_cls`` belongs to a known framework module. + """ + module = getattr(state_cls, "__original_module__", None) or state_cls.__module__ + return module in _FRAMEWORK_STATE_MODULES + + +def install_minify_resolver() -> None: + """Install a fresh :class:`MinifyNameResolver` into the active context. + + Must run before any state class registers — :func:`VarData.from_state` + captures the state's full name at Var-creation time, so a later install + leaves dangling references in the generated frontend. + """ + from reflex_base.registry import RegistrationContext + + ctx = RegistrationContext.ensure_context() + ctx.set_name_resolver(MinifyNameResolver.from_disk()) + + +def ensure_minify_resolver_for_active_context() -> None: + """Install a :class:`MinifyNameResolver` if one isn't already in place. + + Idempotent — safe to wire into hot paths like + :func:`reflex.utils.prerequisites.get_app`. Re-installs only when on-disk + state could have changed (config appearing, or non-minify resolver). + """ + from reflex_base.registry import RegistrationContext + + ctx = RegistrationContext.ensure_context() + if isinstance(ctx.name_resolver, MinifyNameResolver): + if ctx.name_resolver.config is not None: + return + if not _get_minify_json_path().exists(): + return + ctx.set_name_resolver(MinifyNameResolver.from_disk()) + + +def clear_config_cache() -> None: + """Reload ``minify.json`` and propagate the new names through the registry. + + Call after editing ``minify.json`` programmatically or changing + ``REFLEX_MINIFY_*`` env vars at runtime. + """ + get_minify_config.cache_clear() + is_mode_enabled.cache_clear() + install_minify_resolver() + + +# 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: + """Encode a non-negative integer as a base-54 minified name. + + Args: + id_: The integer to encode. + + Returns: + e.g. ``0 → "a"``, ``25 → "z"``, ``54 → "ba"``. + + Raises: + ValueError: If ``id_`` is negative. + """ + if id_ < 0: + msg = f"ID must be non-negative, got {id_}" + raise ValueError(msg) + 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: + """Decode a base-54 minified name back to its integer id. + + Args: + name: The minified string. + + 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: + """Build the unique ``module.Class.SubClass`` path for a state class. + + Uses ``__original_module__`` when available so dynamically-relocated + states (e.g. ``ComponentState.create()``) keep their import-site path. + + Args: + state_cls: The state class. + + Returns: + e.g. ``"myapp.state.AppState.UserState"``. + """ + module = getattr(state_cls, "__original_module__", None) or state_cls.__module__ + class_hierarchy: list[str] = [] + 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] + class_hierarchy.reverse() + return ".".join([module, *class_hierarchy]) + + +def get_parent_key(state_cls: type[BaseState]) -> str | None: + """Return the config key of ``state_cls``'s parent state. + + Args: + state_cls: The state class. + + Returns: + The parent's :func:`get_state_full_path`, or ``None`` for root states. + """ + parent = state_cls.get_parent_state() + return get_state_full_path(parent) if parent is not None else None + + +def collect_all_states( + root_state: type[BaseState] | None = None, +) -> list[type[BaseState]]: + """Collect state classes in deterministic depth-first, sibling-sorted order. + + Without ``root_state``, walks every state registered in the active + :class:`~reflex_base.registry.RegistrationContext` (one tree per + parentless root). With ``root_state``, restricts the walk to that subtree. + + Args: + root_state: Optional subtree root. + + Returns: + State classes in depth-first, sibling-sorted order. + """ + if root_state is not None: + result = [root_state] + for substate in sorted(root_state.get_substates(), key=lambda s: s.__name__): + result.extend(collect_all_states(substate)) + return result + + from reflex_base.registry import RegistrationContext + + ctx = RegistrationContext.get() + roots = sorted( + (cls for cls in ctx.base_states.values() if cls.get_parent_state() is None), + key=lambda s: s.__name__, + ) + out: list[type[BaseState]] = [] + for root in roots: + out.extend(collect_all_states(root)) + return out + + +def generate_minify_config( + root_state: type[BaseState] | None = None, +) -> MinifyConfig: + """Generate a complete minify configuration. + + Walks the state tree (see :func:`collect_all_states`) and assigns ids + starting from ``"a"`` per sibling group. Output is byte-stable. + + Args: + root_state: Optional subtree root. + + Returns: + A complete :class:`MinifyConfig`. + """ + states: dict[str, StateEntry] = {} + events: dict[str, dict[str, str]] = {} + sibling_counter: dict[type[BaseState] | None, int] = {} + + for state_cls in collect_all_states(root_state): + parent = state_cls.get_parent_state() + sibling_counter.setdefault(parent, 0) + state_id = sibling_counter[parent] + sibling_counter[parent] += 1 + + state_path = get_state_full_path(state_cls) + states[state_path] = StateEntry( + id=int_to_minified_name(state_id), parent=get_parent_key(state_cls) + ) + + handler_names = sorted(state_cls.event_handlers.keys()) + if handler_names: + events[state_path] = { + handler_name: int_to_minified_name(event_id) + for event_id, handler_name in enumerate(handler_names) + } + + return MinifyConfig( + version=SCHEMA_VERSION, + states=states, + events=events, + ) + + +def _find_duplicate_ids(items: Iterable[tuple[str, str]]) -> dict[str, list[str]]: + """Group ``(label, minified_id)`` pairs by id, keeping only collisions. + + Args: + items: Pairs of ``(label, minified_id)``. + + Returns: + Mapping from minified id to the labels sharing it (always ``len >= 2``). + """ + by_id: dict[str, list[str]] = {} + for label, mid in items: + by_id.setdefault(mid, []).append(label) + return {mid: labels for mid, labels in by_id.items() if len(labels) > 1} + + +def _assign_next_ids( + new_keys: Iterable[str], + existing_ids: set[int], + reassign_deleted: bool, +) -> dict[str, str]: + """Assign minified ids to ``new_keys`` while skipping ``existing_ids``. + + Keys are sorted for deterministic output. ``existing_ids`` is read-only — + a working copy is taken internally. + + Args: + new_keys: Keys needing new ids. + existing_ids: Already-used integer ids in the same scope. + reassign_deleted: When ``True``, scan from 0 (filling gaps); + otherwise start past the current max. + + Returns: + Mapping from key to its newly-assigned minified id. + """ + pool = set(existing_ids) + next_id = 0 if reassign_deleted else max(pool, default=-1) + 1 + out: dict[str, str] = {} + for key in sorted(new_keys): + while next_id in pool: + next_id += 1 + out[key] = int_to_minified_name(next_id) + pool.add(next_id) + next_id += 1 + return out + + +def validate_minify_config( + config: MinifyConfig, + root_state: type[BaseState] | None = None, +) -> tuple[list[str], list[str], list[str]]: + """Validate a minify configuration against the current state tree. + + Args: + config: The configuration to validate. + root_state: Optional subtree root. ``None`` validates against every + state in the active context. + + Returns: + A tuple ``(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 the config + """ + errors: list[str] = [] + warnings: list[str] = [] + + all_states = collect_all_states(root_state) + + # Group siblings by parent key: live entries via their actual parent class, + # orphans via the parent recorded in the config — so id reuse between a + # dead entry and a live sibling is detected. + path_to_cls = {get_state_full_path(s): s for s in all_states} + parent_to_pairs: dict[str | None, list[tuple[str, str]]] = {} + for state_path, entry in config["states"].items(): + state_cls = path_to_cls.get(state_path) + if state_cls is not None: + parent_key = get_parent_key(state_cls) + if entry["parent"] != parent_key: + warnings.append( + f"Stale parent for '{state_path}': config has " + f"{entry['parent']!r}, actual is {parent_key!r}. " + "Run 'reflex minify sync' to fix." + ) + label = state_path + else: + parent_key = entry["parent"] + label = f"{state_path} (orphaned)" + parent_to_pairs.setdefault(parent_key, []).append((label, entry["id"])) + + for parent_key, pairs in parent_to_pairs.items(): + parent_name = parent_key if parent_key is not None else "root" + errors.extend( + f"Duplicate state_id='{mid}' under '{parent_name}': {paths}" + for mid, paths in _find_duplicate_ids(pairs).items() + ) + + for state_path, state_events in config["events"].items(): + errors.extend( + f"Duplicate event_id='{mid}' in '{state_path}': {handlers}" + for mid, handlers in _find_duplicate_ids(state_events.items()).items() + ) + + code_state_paths = {get_state_full_path(s) for s in all_states} + + # Framework states are never minified, so don't report them as missing. + user_states = [s for s in all_states if not _is_framework_state(s)] + + # Check for missing states (in code but not in config) + missing: list[str] = [ + f"state:{state_path}" + for state_path in (get_state_full_path(s) for s in user_states) + if state_path not in config["states"] + ] + + # Check for missing events (in code but not in config) + for state_cls in user_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.extend( + 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] | None = None, + 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: Optional subtree root. ``None`` syncs against every state + in the active context. + reassign_deleted: If True, fill id gaps left by removed entries. + Retained orphan ids stay reserved unless pruned. + 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[str, StateEntry] = { + k: StateEntry(id=v["id"], parent=v["parent"]) + for k, v in existing_config["states"].items() + } + 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} + + path_to_parent_key = {get_state_full_path(s): get_parent_key(s) for s in all_states} + + # Reserve every configured id within its sibling group — orphans under + # their recorded parent, so their ids are never reused. Live entries get + # their stored parent healed to the actual value. + parent_key_to_existing_ids: dict[str | None, set[int]] = {} + for state_path, entry in new_states.items(): + parent_key = path_to_parent_key.get(state_path, entry["parent"]) + entry["parent"] = parent_key + parent_key_to_existing_ids.setdefault(parent_key, set()).add( + minified_name_to_int(entry["id"]) + ) + + # Find states that need IDs assigned, grouped by parent key. + parent_key_to_new_children: dict[str | None, list[str]] = {} + for state_path, parent_key in path_to_parent_key.items(): + if state_path not in new_states: + parent_key_to_new_children.setdefault(parent_key, []).append(state_path) + + # Assign new state IDs (unique among siblings of the same parent). + for parent_key, children in parent_key_to_new_children.items(): + existing_ids = parent_key_to_existing_ids.get(parent_key, set()) + assigned = _assign_next_ids(children, existing_ids, reassign_deleted) + for state_path, minified_name in assigned.items(): + new_states[state_path] = StateEntry(id=minified_name, parent=parent_key) + + # Assign new event IDs (unique within each state). + 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: + existing_ids = {minified_name_to_int(eid) for eid in state_events.values()} + state_events.update( + _assign_next_ids(new_handlers, existing_ids, reassign_deleted) + ) + 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 3c340fccea2..db7d28ae34b 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -4,7 +4,7 @@ from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, overload import click from reflex_base import constants @@ -19,6 +19,8 @@ from reflex_base.constants.base import LITERAL_ENV from reflex_cli.constants.base import LogLevel as HostingLogLevel + from reflex.minify import MinifyConfig + def set_loglevel(ctx: click.Context, self: click.Parameter, value: str | None): """Set the log level. @@ -925,6 +927,371 @@ 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.""" + + +def _load_app_for_minify() -> None: + """Compile the user's app so dynamic states (e.g. ``ComponentState``) register.""" + from reflex.utils import prerequisites + + prerequisites.get_compiled_app(dry_run=True) + + +def _count_events(config: MinifyConfig) -> int: + """Sum event handlers across every state in a minify config. + + Args: + config: The minify configuration. + + Returns: + Total handler count. + """ + return sum(len(handlers) for handlers in config["events"].values()) + + +@overload +def _open_minify_session(*, require_exists: Literal[True] = True) -> MinifyConfig: ... +@overload +def _open_minify_session(*, require_exists: Literal[False]) -> MinifyConfig | None: ... + + +def _open_minify_session(*, require_exists: bool = True) -> MinifyConfig | None: + """Run the standard minify-CLI prelude. + + Compiles the user's app (so dynamic states register) and loads + ``minify.json``. Exits the CLI with an error when the file is required + but missing, or when it exists but is malformed. + + Args: + require_exists: When ``True`` (default), missing ``minify.json`` is + a fatal error. When ``False``, returns ``None`` instead. + + Returns: + The parsed config, or ``None`` if ``require_exists`` is ``False`` and + the file is absent. + """ + from reflex.minify import ( + MINIFY_JSON, + _get_minify_json_path, + _load_minify_config_uncached, + ) + + exists = _get_minify_json_path().exists() + if require_exists and not exists: + console.error( + f"{MINIFY_JSON} does not exist. Use 'reflex minify init' to create it." + ) + raise SystemExit(1) + + _load_app_for_minify() + + if not exists: + return None + + try: + config = _load_minify_config_uncached() + except ValueError as e: + console.error(str(e)) + raise SystemExit(1) from e + if config is None: + console.error(f"Failed to load {MINIFY_JSON}.") + raise SystemExit(1) + return config + + +@minify.command(name="init") +@loglevel_option +def minify_init(): + """Initialize minify.json with IDs for all states and events.""" + from reflex.minify import ( + MINIFY_JSON, + _get_minify_json_path, + generate_minify_config, + save_minify_config, + ) + + if _get_minify_json_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_app_for_minify() + config = generate_minify_config() + save_minify_config(config) + + console.log( + f"Created {MINIFY_JSON} with {len(config['states'])} states " + f"and {_count_events(config)} 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, save_minify_config, sync_minify_config + + existing_config = _open_minify_session() + new_config = sync_minify_config( + existing_config, reassign_deleted=reassign_deleted, prune=prune + ) + save_minify_config(new_config) + + console.log(f"Updated {MINIFY_JSON}:") + console.log( + f" States: {len(existing_config['states'])} -> {len(new_config['states'])}" + ) + console.log( + f" Events: {_count_events(existing_config)} -> {_count_events(new_config)}" + ) + + +@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, validate_minify_config + + config = _open_minify_session() + errors, warnings, missing = validate_minify_config(config) + + 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.warn("Missing entries (in code but not in config):") + for entry in missing: + console.warn(f" - {entry}") + + if errors or missing: + 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_state_full_path + from reflex.state import BaseState, State + + 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] + + # CLI inspection shows config contents regardless of env var settings. + config = _open_minify_session(require_exists=False) + states_map = config["states"] if config else {} + events_map = config["events"] if config else {} + + 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_entry = states_map.get(state_path) + state_id = state_entry["id"] if state_entry is not None else None + + handler_ids = events_map.get(state_path, {}) + handlers: list[EventHandlerData] = [ + {"name": handler_name, "event_id": handler_ids.get(handler_name)} + for handler_name in sorted(state_cls.event_handlers.keys()) + ] + + # Build substates recursively + substates = [ + build_state_tree(substate) + for substate in sorted(state_cls.get_substates(), 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}{'|' if has_substates else '`'}-- 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 + + click.echo(json.dumps(tree_data, indent=2)) + else: + if config is not None: + 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 collect_all_states, get_state_full_path + from reflex.state import State + + config = _open_minify_session() + + # Build lookup: full_path -> minified_id (None if no entry). + path_to_id: dict[str, str | None] = {} + for s in collect_all_states(State): + path = get_state_full_path(s) + entry = config["states"].get(path) + path_to_id[path] = entry["id"] if entry is not None else None + + parts = minified_path.split(".") + result_parts = [] + current = State + + for i, part in enumerate(parts): + # Find the state whose minified id matches ``part``: the root state + # for the first segment, otherwise a child of the previous match. + candidates = [current] if i == 0 else current.get_substates() + found = next( + (c for c in candidates if path_to_id.get(get_state_full_path(c)) == part), + None, + ) + 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) + result_parts.append({ + "minified": part, + "state_id": part, # we just matched on it + "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 a488cab97e7..fc3b66f9300 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -36,6 +36,7 @@ EventHandler, EventSpec, call_script, + typing_event, ) from reflex_base.utils.exceptions import ( ComputedVarShadowsBaseVarsError, @@ -75,12 +76,18 @@ from reflex.istate.proxy import ImmutableMutableProxy as ImmutableMutableProxy from reflex.istate.proxy import MutableProxy, is_mutable_type from reflex.istate.storage import ClientStorageBase +from reflex.minify import ensure_minify_resolver_for_active_context from reflex.utils import console, format, prerequisites, types from reflex.utils.exec import is_testing_env if TYPE_CHECKING: from reflex_base.components.component import Component +# Must run before any state class registers — a later install renames states +# while names captured by VarData.from_state keep the old value (e.g. granian +# prod workers import the app module directly, bypassing get_app). +ensure_minify_resolver_for_active_context() + Delta = dict[str, dict[str, Any]] DeltaMapping = Mapping[str, Mapping[str, Any]] @@ -725,16 +732,20 @@ 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) + return handler @staticmethod def _copy_fn(fn: Callable) -> Callable: @@ -1059,13 +1070,21 @@ def get_substates(cls) -> set[type[BaseState]]: @classmethod @functools.lru_cache def get_name(cls) -> str: - """Get the name of the state. + """Get the user-visible name of the state. + + Defers to the active :class:`~reflex_base.registry.NameResolver` + (e.g. ``minify.json`` rewrites), falling back to the built-in + snake-cased ``module___ClassName`` when no context is attached. Returns: - The name of the state. + The resolved name of the state. """ - module = cls.__module__.replace(".", "___") - return format.to_snake_case(f"{module}___{cls.__name__}") + from reflex_base.registry import RegistrationContext + + ctx = RegistrationContext.try_get() + if ctx is None: + return RegistrationContext.default_state_name(cls) + return ctx.get_state_name(cls) @classmethod @functools.lru_cache @@ -1083,11 +1102,16 @@ 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: Internal recursion flag; leave at the default. Allows + ``"a.b.b"`` to resolve to a child that shares its parent's + minified name. Returns: The class substate. @@ -1100,13 +1124,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) @@ -1252,7 +1276,9 @@ 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 + ) @classmethod def _create_setter(cls, name: str, prop: Var): @@ -1610,11 +1636,14 @@ 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: Internal recursion flag; leave at the default. Allows + ``"a.b.b"`` to resolve to a child that shares its parent's + minified name. Returns: The substate. @@ -1624,14 +1653,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]]: @@ -2378,7 +2407,7 @@ class FrontendEventExceptionState(State): ), ] - @event + @typing_event def handle_frontend_exception( self, info: str, component_stack: str ) -> Iterator[EventSpec]: @@ -2414,6 +2443,7 @@ def handle_frontend_exception( class UpdateVarsInternalState(State): """Substate for handling internal state var updates.""" + @typing_event async def update_vars_internal(self, vars: dict[str, Any]) -> None: """Apply updates to fully qualified state vars. @@ -2444,6 +2474,7 @@ class OnLoadInternalState(State): # Cannot properly annotate this as `App` due to circular import issues. _app_ref: ClassVar[Any] = None + @typing_event def on_load_internal(self) -> list[Event | EventSpec | event.EventCallback] | None: """Queue on_load handlers for the current page. diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index a8c84da6aaa..ab22dd2ec7f 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -183,6 +183,7 @@ def get_app(reload: bool = False) -> ModuleType: Raises: Exception: If an error occurs while getting the app module. """ + from reflex.minify import ensure_minify_resolver_for_active_context from reflex.utils import telemetry try: @@ -194,6 +195,9 @@ def get_app(reload: bool = False) -> ModuleType: module = config.module sys.path.insert(0, getcwd()) # noqa: PTH109 + # Resolver must be active before the user module imports — see + # ``ensure_minify_resolver_for_active_context`` for why. + ensure_minify_resolver_for_active_context() app = ( __import__(module, fromlist=(constants.CompileVars.APP,)) if not config.app_module diff --git a/tests/integration/test_minification.py b/tests/integration/test_minification.py new file mode 100644 index 00000000000..4e405b8d218 --- /dev/null +++ b/tests/integration/test_minification.py @@ -0,0 +1,215 @@ +"""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, + SCHEMA_VERSION, + 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 with one root + one substate, each with one event handler.""" + import reflex as rx + from reflex.utils import format + + class RootState(rx.State): + count: int = 0 + + @rx.event + def increment(self): + self.count += 1 + + class SubState(RootState): + message: str = "hello" + + @rx.event + def update_message(self): + parent = self.parent_state + assert parent is not None + assert isinstance(parent, RootState) + self.message = f"count is {parent.count}" + + 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(RootState.count, id="count_value"), + 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) + + +# Framework state classes (e.g. ``reflex.state.State``) are deliberately +# absent — the resolver never minifies them. +_MINIFY_CONFIG = { + "version": SCHEMA_VERSION, + "states": { + "minify_enabled.minify_enabled.State.RootState": { + "id": "k", # 10 + "parent": "reflex.state.State", + }, + "minify_enabled.minify_enabled.State.RootState.SubState": { + "id": "l", # 11 + "parent": "minify_enabled.minify_enabled.State.RootState", + }, + }, + "events": { + "minify_enabled.minify_enabled.State.RootState": {"increment": "f"}, # 5 + "minify_enabled.minify_enabled.State.RootState.SubState": { + "update_message": "h" # 7 + }, + }, +} + + +@pytest.fixture(params=[False, True], ids=["disabled", "enabled"]) +def minify_app( + request: pytest.FixtureRequest, + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[tuple[bool, AppHarness], None, None]: + """Run :func:`MinificationApp` with minification on (parametrized). + + Yields: + ``(minify_enabled, harness)``. + """ + enabled: bool = request.param + if enabled: + monkeypatch.setenv( + environment.REFLEX_MINIFY_STATES.name, MinifyMode.ENABLED.value + ) + monkeypatch.setenv( + environment.REFLEX_MINIFY_EVENTS.name, MinifyMode.ENABLED.value + ) + clear_config_cache() + + app_name = "minify_enabled" if enabled else "minify_disabled" + app_root = tmp_path_factory.mktemp(app_name) + harness = app_harness_env.create( + root=app_root, app_name=app_name, app_source=MinificationApp + ) + if enabled: + (app_root / MINIFY_JSON).write_text(json.dumps(_MINIFY_CONFIG)) + + with harness: + yield enabled, harness + + +@pytest.fixture +def driver( + minify_app: tuple[bool, AppHarness], +) -> Generator[WebDriver, None, None]: + """WebDriver scoped to the parametrized :func:`minify_app`. + + Yields: + A WebDriver pointed at the running app. + """ + _enabled, harness = minify_app + assert harness.app_instance is not None, "app is not running" + drv = harness.frontend() + try: + yield drv + finally: + drv.quit() + + +def _text_after_colon(text: str) -> str: + """Strip the ``"label: "`` prefix from a UI element's text. + + Args: + text: The element text. + + Returns: + The substring after the first ``": "``, or ``text`` unchanged. + """ + return text.split(": ", 1)[-1] if ": " in text else text + + +def test_minification( + minify_app: tuple[bool, AppHarness], + driver: WebDriver, +) -> None: + """State and event handler names follow the minify config when enabled.""" + enabled, harness = minify_app + assert harness.app_instance is not None + + token_input = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "token") + ) + assert harness.poll_for_value(token_input) + + root_name = driver.find_element(By.ID, "root_state_name").text + sub_name = driver.find_element(By.ID, "sub_state_name").text + increment_name = _text_after_colon( + driver.find_element(By.ID, "increment_handler_name").text + ) + update_name = _text_after_colon( + driver.find_element(By.ID, "update_handler_name").text + ) + + if enabled: + assert int_to_minified_name(10) in root_name + assert int_to_minified_name(11) in sub_name + assert increment_name.endswith(f".{int_to_minified_name(5)}") + assert update_name.endswith(f".{int_to_minified_name(7)}") + assert "increment" not in increment_name.lower() + assert "update_message" not in update_name.lower() + else: + assert "root_state" in root_name.lower() + assert "sub_state" in sub_name.lower() + assert "increment" in increment_name.lower() + assert "update_message" in update_name.lower() + assert "." in increment_name + assert "." in update_name + + # Event dispatch sanity check (must work regardless of minification). + count = driver.find_element(By.ID, "count_value") + assert count.text == "0" + driver.find_element(By.ID, "increment_btn").click() + AppHarness._poll_for(lambda: count.text == "1") + + if enabled: + # Substate handler dispatch through minified names. + message = driver.find_element(By.ID, "message_value") + driver.find_element(By.ID, "update_msg_btn").click() + AppHarness._poll_for(lambda: "count is 1" in message.text) + assert message.text == "count is 1" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 270f040f684..713b232845e 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1890,7 +1890,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"{OnLoadInternalState.get_full_name()}.{constants.CompileVars.ON_LOAD_INTERNAL.rpartition('.')[2]}", + name=f"{OnLoadInternalState.get_full_name()}.on_load_internal", val=exp_val, ) exp_router = RouterData.from_router_data(on_load_internal.router_data) diff --git a/tests/units/test_minification.py b/tests/units/test_minification.py new file mode 100644 index 00000000000..396246e169d --- /dev/null +++ b/tests/units/test_minification.py @@ -0,0 +1,1401 @@ +"""Unit tests for state and event handler minification via minify.json.""" + +from __future__ import annotations + +import contextlib +import json +from collections.abc import Iterator +from pathlib import Path +from unittest import mock + +import pytest +from click.testing import CliRunner +from reflex_base.registry import DefaultNameResolver, NameResolver, RegistrationContext + +from reflex.environment import MinifyMode, environment +from reflex.minify import ( + MINIFY_JSON, + SCHEMA_VERSION, + MinifyConfig, + MinifyNameResolver, + StateEntry, + clear_config_cache, + generate_minify_config, + get_minify_config, + get_parent_key, + get_state_full_path, + int_to_minified_name, + is_minify_enabled, + is_mode_enabled, + minified_name_to_int, + save_minify_config, + sync_minify_config, + validate_minify_config, +) +from reflex.state import BaseState, State + + +def _resolved_event_id(state_cls: type[BaseState], handler_name: str) -> str | None: + """Ask the active resolver for the minified name of a handler. + + Args: + state_cls: The state class the handler is attached to. + handler_name: The handler's original (Python) name. + + Returns: + The minified id, or ``None`` if the resolver doesn't override it. + """ + return RegistrationContext.get().name_resolver.resolve_handler_name( + state_cls, handler_name + ) + + +def _set_minify_modes( + monkeypatch: pytest.MonkeyPatch, + *, + states: MinifyMode | None = None, + events: MinifyMode | None = None, +) -> None: + """Set ``REFLEX_MINIFY_*`` env vars; ``None`` leaves the var unchanged. + + Args: + monkeypatch: The pytest monkeypatch fixture. + states: Mode for ``REFLEX_MINIFY_STATES``. + events: Mode for ``REFLEX_MINIFY_EVENTS``. + """ + if states is not None: + monkeypatch.setenv(environment.REFLEX_MINIFY_STATES.name, states.value) + if events is not None: + monkeypatch.setenv(environment.REFLEX_MINIFY_EVENTS.name, events.value) + + +def _install_config( + states: dict[str, str | StateEntry] | None = None, + events: dict[str, dict[str, str]] | None = None, + *, + include_state_root: bool = False, +) -> MinifyConfig: + """Build, save, and activate a ``minify.json`` in one call. + + Calls ``clear_config_cache()`` afterward — that re-installs the resolver + and clears every per-class lru_cache, so tests don't need to call + ``State.get_name.cache_clear()`` etc. by hand. + + Args: + states: ``state_path -> minified_id`` map. Plain string values are + wrapped into :class:`StateEntry` with ``parent=None``. + events: ``state_path -> {handler -> minified_id}`` map. + include_state_root: Add ``"reflex.state.State": "a"`` so subclasses + of ``State`` resolve through the root entry. + + Returns: + The saved config. + """ + states_map: dict[str, StateEntry] = { + path: value if isinstance(value, dict) else StateEntry(id=value, parent=None) + for path, value in (states or {}).items() + } + if include_state_root: + states_map.setdefault("reflex.state.State", StateEntry(id="a", parent=None)) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": states_map, + "events": events or {}, + } + save_minify_config(config) + clear_config_cache() + return config + + +@contextlib.contextmanager +def _temporary_resolver(resolver: NameResolver) -> Iterator[RegistrationContext]: + """Install ``resolver`` for the duration of the ``with`` block. + + Args: + resolver: The resolver to install temporarily. + + Yields: + The active registration context. + """ + ctx = RegistrationContext.get() + original = ctx.name_resolver + try: + ctx.set_name_resolver(resolver) + yield ctx + finally: + ctx.set_name_resolver(original) + + +@pytest.fixture +def cli_runner(monkeypatch: pytest.MonkeyPatch) -> CliRunner: + """Click runner with ``prerequisites.get_compiled_app`` stubbed out. + + Returns: + A ``CliRunner`` ready to invoke ``reflex.reflex.cli`` commands. + """ + from reflex.utils import prerequisites + + monkeypatch.setattr(prerequisites, "get_compiled_app", lambda *a, **kw: mock.Mock()) + return CliRunner() + + +def _stub_resolver( + *, + state_name: str | None = None, + target: type[BaseState] = State, + handler_prefix: str | None = None, +) -> NameResolver: + """Build a tiny one-off :class:`NameResolver` for tests. + + Args: + state_name: Override returned for ``target`` (else ``None``). + target: Which state class the override scopes to. + handler_prefix: When set, prefixes every handler name. + + Returns: + A resolver with the requested behavior. + """ + + class _Stub: + def resolve_state_name(self, state_cls): + return state_name if state_cls is target else None + + def resolve_handler_name(self, state_cls, handler_name): + return f"{handler_prefix}{handler_name}" if handler_prefix else None + + return _Stub() + + +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): + """Run the test against a fresh ``minify.json`` location. + + Teardown undoes monkeypatch *before* clearing the cache so the registry + is rebuilt under unmodified env/cwd — otherwise other tests would inherit + minified names. + + Yields: + The temporary directory path. + """ + monkeypatch.chdir(tmp_path) + clear_config_cache() + yield tmp_path + monkeypatch.undo() + clear_config_cache() + + +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_minify_config() is None + + def test_save_and_load_config(self, temp_minify_json, monkeypatch): + """Test saving and loading a config.""" + _set_minify_modes( + monkeypatch, states=MinifyMode.ENABLED, events=MinifyMode.ENABLED + ) + _install_config( + states={"test.module.MyState": "a"}, + events={"test.module.MyState": {"handler": "a"}}, + ) + + assert is_minify_enabled() is True + loaded = get_minify_config() + assert loaded is not None + assert loaded["states"]["test.module.MyState"] == {"id": "a", "parent": None} + assert loaded["events"]["test.module.MyState"]["handler"] == "a" + + def test_invalid_version_raises(self, temp_minify_json, monkeypatch): + """Test that invalid version raises ValueError.""" + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + 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_mode_enabled("REFLEX_MINIFY_STATES") + + def test_missing_states_raises(self, temp_minify_json, monkeypatch): + """Test that missing 'states' key raises ValueError.""" + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + 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_mode_enabled("REFLEX_MINIFY_STATES") + + def test_flat_string_states_raise(self, temp_minify_json, monkeypatch): + """Test that legacy flat string state values are rejected.""" + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + config = { + "version": SCHEMA_VERSION, + "states": {"test.module.MyState": "a"}, + "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="must be an object with a string 'id'"): + is_mode_enabled("REFLEX_MINIFY_STATES") + + +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"] + assert config["states"]["reflex.state.State"]["parent"] is None + # 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_entry = config["states"].get(child_a_path) + child_b_entry = config["states"].get(child_b_path) + + assert child_a_entry is not None + assert child_b_entry is not None + assert child_a_entry["id"] != child_b_entry["id"] + parent_path = get_state_full_path(ParentState) + assert child_a_entry["parent"] == parent_path + assert child_b_entry["parent"] == parent_path + + +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": StateEntry(id="a", parent=None), + "test.Parent.ChildA": StateEntry(id="b", parent="test.Parent"), + "test.Parent.ChildB": StateEntry( # Duplicate! + id="b", parent="test.Parent" + ), + }, + "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) + + def test_validate_detects_orphan_sibling_collision(self): + """An orphaned entry sharing an id with a live sibling is an error.""" + + class OrphanCollisionParent(BaseState): + pass + + class OrphanCollisionLiveChild(OrphanCollisionParent): + pass + + parent_path = get_state_full_path(OrphanCollisionParent) + live_path = get_state_full_path(OrphanCollisionLiveChild) + orphan_path = f"{parent_path}.DeadChild" + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + live_path: StateEntry(id="b", parent=parent_path), + orphan_path: StateEntry(id="b", parent=parent_path), # collision! + }, + "events": {}, + } + + errors, _warnings, _missing = validate_minify_config( + config, OrphanCollisionParent + ) + + assert any( + "Duplicate state_id='b'" in e and "(orphaned)" in e for e in errors + ), f"Expected orphan collision error, got: {errors}" + + def test_validate_no_cross_group_orphan_false_positive(self): + """An orphan of a different parent may share an id with a root state.""" + + class OrphanNoFalsePositiveRoot(BaseState): + pass + + root_path = get_state_full_path(OrphanNoFalsePositiveRoot) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + root_path: StateEntry(id="a", parent=None), + # Orphan from an unrelated (deleted) parent, same id "a". + "gone.module.Gone.Child": StateEntry(id="a", parent="gone.module.Gone"), + }, + "events": {}, + } + + errors, warnings, _missing = validate_minify_config( + config, OrphanNoFalsePositiveRoot + ) + + assert not errors, f"Unexpected errors: {errors}" + assert any("Orphaned state" in w for w in warnings) + + def test_validate_ignores_framework_states_in_missing(self): + """Framework states/handlers omitted from a user-only config aren't missing.""" + + class UserOnlyState(State): + def do_thing(self): + pass + + user_path = get_state_full_path(UserOnlyState) + # Config omits framework reflex.state.State entries. + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {user_path: StateEntry(id="a", parent="reflex.state.State")}, + "events": {user_path: {"do_thing": "a"}}, + } + + _errors, _warnings, missing = validate_minify_config(config, State) + + assert not any(entry.startswith("state:reflex.state.") for entry in missing) + assert not any(entry.startswith("event:reflex.state.") for entry in missing) + assert f"state:{user_path}" not in missing + assert f"event:{user_path}.do_thing" not in missing + + def test_validate_warns_stale_parent(self): + """A live entry whose stored parent disagrees with the code is flagged.""" + + class StaleParentParent(BaseState): + pass + + class StaleParentChild(StaleParentParent): + pass + + parent_path = get_state_full_path(StaleParentParent) + child_path = get_state_full_path(StaleParentChild) + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + child_path: StateEntry(id="a", parent="wrong.Path"), + }, + "events": {}, + } + + errors, warnings, _missing = validate_minify_config(config, StaleParentParent) + + assert not errors, f"Unexpected errors: {errors}" + assert any("Stale parent" in w and child_path in w for w in warnings) + + +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 with its parent key recorded + state_path = get_state_full_path(TestState) + assert state_path in new_config["states"] + assert new_config["states"][state_path]["parent"] == get_parent_key(TestState) + 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 + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + state_path: StateEntry(id="bU", parent=None) # 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]["id"] == "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' + + def test_sync_no_sibling_collision_across_modules(self): + """Test that sync assigns unique IDs to siblings of the same parent. + + When children of the same parent state class are defined in different + Python modules, their get_state_full_path() produces different string + prefixes. The sync function must group siblings by the actual parent + class object, not by string-splitting the path, to avoid ID collisions. + """ + + class ParentState(BaseState): + pass + + class ChildA(ParentState): + pass + + class ChildB(ParentState): + pass + + parent_path = get_state_full_path(ParentState) + child_a_path = get_state_full_path(ChildA) + + # Config already has ParentState and ChildA assigned + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + child_a_path: StateEntry(id="a", parent=parent_path), + }, + "events": {}, + } + + # Sync should assign ChildB a DIFFERENT ID than ChildA + new_config = sync_minify_config(existing_config, ParentState) + + child_b_path = get_state_full_path(ChildB) + assert child_b_path in new_config["states"] + assert ( + new_config["states"][child_a_path]["id"] + != new_config["states"][child_b_path]["id"] + ), ( + f"Sibling collision: ChildA and ChildB both got " + f"'{new_config['states'][child_a_path]['id']}'" + ) + + def test_validate_detects_sibling_collision(self): + """Test that validate catches duplicate IDs among siblings of same parent.""" + + class ParentState(BaseState): + pass + + class ChildA(ParentState): + pass + + class ChildB(ParentState): + pass + + parent_path = get_state_full_path(ParentState) + child_a_path = get_state_full_path(ChildA) + child_b_path = get_state_full_path(ChildB) + + # Manually create a config with colliding sibling IDs + bad_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + child_a_path: StateEntry(id="a", parent=parent_path), + child_b_path: StateEntry(id="a", parent=parent_path), # collision! + }, + "events": {}, + } + + errors, _warnings, _missing = validate_minify_config(bad_config, ParentState) + assert any("Duplicate" in e and "'a'" in e for e in errors), ( + f"Expected duplicate ID error, got: {errors}" + ) + + def test_sync_reserves_orphan_ids(self): + """A new sibling must not reuse the id of an orphaned entry.""" + + class OrphanReserveParent(BaseState): + pass + + class OrphanReserveRenamedChild(OrphanReserveParent): + pass + + parent_path = get_state_full_path(OrphanReserveParent) + orphan_path = f"{parent_path}.OldChild" + + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + orphan_path: StateEntry(id="a", parent=parent_path), + }, + "events": {}, + } + + new_config = sync_minify_config(existing_config, OrphanReserveParent) + + renamed_path = get_state_full_path(OrphanReserveRenamedChild) + assert new_config["states"][orphan_path] == StateEntry( + id="a", parent=parent_path + ) + assert new_config["states"][renamed_path]["id"] != "a" + + def test_sync_reassign_deleted_keeps_orphan_ids_reserved(self): + """``reassign_deleted`` fills gaps but never reuses retained orphan ids.""" + + class OrphanReassignParent(BaseState): + pass + + class OrphanReassignChild(OrphanReassignParent): + pass + + parent_path = get_state_full_path(OrphanReassignParent) + orphan_path = f"{parent_path}.OldChild" + + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + orphan_path: StateEntry(id="a", parent=parent_path), + }, + "events": {}, + } + + new_config = sync_minify_config( + existing_config, OrphanReassignParent, reassign_deleted=True + ) + + child_path = get_state_full_path(OrphanReassignChild) + assert new_config["states"][child_path]["id"] != "a" + + def test_sync_prune_frees_orphan_ids(self): + """``prune`` removes orphans, freeing their ids for reassignment.""" + + class OrphanPruneParent(BaseState): + pass + + class OrphanPruneChild(OrphanPruneParent): + pass + + parent_path = get_state_full_path(OrphanPruneParent) + orphan_path = f"{parent_path}.OldChild" + + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + orphan_path: StateEntry(id="a", parent=parent_path), + }, + "events": {}, + } + + new_config = sync_minify_config( + existing_config, OrphanPruneParent, reassign_deleted=True, prune=True + ) + + child_path = get_state_full_path(OrphanPruneChild) + assert orphan_path not in new_config["states"] + assert new_config["states"][child_path]["id"] == "a" + + def test_sync_heals_stale_parent(self): + """Sync rewrites a live entry's stored parent to the actual value.""" + + class HealParentParent(BaseState): + pass + + class HealParentChild(HealParentParent): + pass + + parent_path = get_state_full_path(HealParentParent) + child_path = get_state_full_path(HealParentChild) + + existing_config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + parent_path: StateEntry(id="a", parent=None), + child_path: StateEntry(id="a", parent="wrong.Path"), + }, + "events": {}, + } + + new_config = sync_minify_config(existing_config, HealParentParent) + + assert new_config["states"][child_path] == StateEntry( + id="a", parent=parent_path + ) + + +class TestStateMinification: + """Tests for state name minification with minify.json.""" + + @pytest.mark.parametrize( + ("mode", "expect_minified"), + [(None, False), (MinifyMode.ENABLED, True), (MinifyMode.DISABLED, False)], + ) + def test_state_name_resolution( + self, temp_minify_json, monkeypatch, mode, expect_minified + ): + """Minified name only when env is ENABLED and config has the entry.""" + if mode is not None: + _set_minify_modes(monkeypatch, states=mode) + + class TestState(BaseState): + pass + + if mode is not None: + _install_config(states={get_state_full_path(TestState): "f"}) + + name = TestState.get_name() + assert (name == "f") is expect_minified + if not expect_minified: + 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): + """No minify.json → handlers keep their Python names.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + class TestState(BaseState): + @rx.event + def my_handler(self): + pass + + _, event_name = get_event_handler_parts(TestState.event_handlers["my_handler"]) + assert event_name == "my_handler" + + def test_event_uses_minified_name_with_config(self, temp_minify_json, monkeypatch): + """Handler name follows the config when ``REFLEX_MINIFY_EVENTS`` is on.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + _set_minify_modes(monkeypatch, events=MinifyMode.ENABLED) + state_path = "tests.units.test_minification.State.TestStateMinifiedEvent" + _install_config( + states={state_path: "b"}, + events={state_path: {"my_handler": "d"}}, + include_state_root=True, + ) + + class TestStateMinifiedEvent(State): + @rx.event + def my_handler(self): + pass + + _, name = get_event_handler_parts( + TestStateMinifiedEvent.event_handlers["my_handler"] + ) + assert name == "d" + + def test_event_uses_full_name_when_env_disabled( + self, temp_minify_json, monkeypatch + ): + """``REFLEX_MINIFY_EVENTS=disabled`` keeps full handler names.""" + import reflex as rx + from reflex.utils.format import get_event_handler_parts + + _set_minify_modes(monkeypatch, events=MinifyMode.DISABLED) + state_path = "tests.units.test_minification.State.TestStateMinifiedEventOff" + _install_config( + states={state_path: "b"}, + events={state_path: {"my_handler": "d"}}, + include_state_root=True, + ) + + class TestStateMinifiedEventOff(State): + @rx.event + def my_handler(self): + pass + + _, name = get_event_handler_parts( + TestStateMinifiedEventOff.event_handlers["my_handler"] + ) + assert 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 resolvable to its minified name.""" + _set_minify_modes(monkeypatch, events=MinifyMode.ENABLED) + state_path = "tests.units.test_minification.State.TestStateWithSetvar" + _install_config( + states={state_path: "b"}, + events={state_path: {"setvar": "s"}}, + include_state_root=True, + ) + + class TestStateWithSetvar(State): + pass + + assert _resolved_event_id(TestStateWithSetvar, "setvar") == "s" + + def test_auto_setter_registered_with_config(self, temp_minify_json, monkeypatch): + """Test that auto-setters (set_*) are resolvable to their minified name.""" + from reflex_base import config as base_config + + _set_minify_modes(monkeypatch, events=MinifyMode.ENABLED) + # state_auto_setters is False by default; force it on so that + # `_init_var` actually creates the setter we want to verify. + real_get_config = base_config.get_config + + def _mock_get_config(*args, **kwargs): + cfg = real_get_config(*args, **kwargs) + cfg.state_auto_setters = True + return cfg + + monkeypatch.setattr(base_config, "get_config", _mock_get_config) + state_path = "tests.units.test_minification.State.TestStateWithAutoSetter" + _install_config( + states={state_path: "b"}, + events={state_path: {"set_count": "c", "setvar": "v"}}, + include_state_root=True, + ) + + class TestStateWithAutoSetter(State): + count: int = 0 + + assert _resolved_event_id(TestStateWithAutoSetter, "set_count") == "c" + + def test_dynamic_handlers_not_registered_without_config(self, temp_minify_json): + """Test that dynamic handlers have no resolved minified name without config.""" + + class TestStateNoConfig(State): + count: int = 0 + + for handler_name in TestStateNoConfig.event_handlers: + assert _resolved_event_id(TestStateNoConfig, handler_name) is None + + 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 + + _set_minify_modes(monkeypatch, events=MinifyMode.ENABLED) + state_path = "tests.units.test_minification.State.TestStateWithDynamicHandler" + _install_config( + states={state_path: "b"}, + events={state_path: {"dynamic_handler": "d", "setvar": "v"}}, + include_state_root=True, + ) + + class TestStateWithDynamicHandler(State): + pass + + @rx.event + def dynamic_handler(self): + pass + + TestStateWithDynamicHandler._add_event_handler( + "dynamic_handler", dynamic_handler + ) + + assert _resolved_event_id(TestStateWithDynamicHandler, "dynamic_handler") == "d" + + def test_component_state_picks_up_minified_name( + self, temp_minify_json, monkeypatch + ): + """``ComponentState.create()`` instances are real state classes too. + + They register via ``__init_subclass__`` like any other state, so as + long as the minify resolver is installed before ``create()`` runs, + the resulting class gets the minified name from ``minify.json``. + """ + import reflex as rx + + _set_minify_modes( + monkeypatch, states=MinifyMode.ENABLED, events=MinifyMode.ENABLED + ) + # ComponentState.create() builds a new class via ``type(...)`` with + # ``__module__ = "reflex.istate.dynamic"`` and a ``_n`` suffix, + # so the path under which the resolver will look it up is fully + # determined ahead of time. + instance_count = rx.ComponentState._per_component_state_instance_count + 1 + instance_path = ( + f"reflex.istate.dynamic.State.ComponentStateMinifyExample_n{instance_count}" + ) + _install_config( + states={instance_path: "z"}, + events={instance_path: {"increment": "i", "setvar": "s"}}, + include_state_root=True, + ) + + class ComponentStateMinifyExample(rx.ComponentState): + count: int = 0 + + @rx.event + def increment(self): + self.count += 1 + + @classmethod + def get_component(cls, **props): + return rx.fragment() + + ComponentStateMinifyExample.create() + instance_cls = next( + cls + for cls in RegistrationContext.get().base_states.values() + if cls.__name__ == f"ComponentStateMinifyExample_n{instance_count}" + ) + + assert instance_cls.get_name() == "z" + assert _resolved_event_id(instance_cls, "increment") == "i" + + def test_state_created_after_resolver_install_uses_minified_name( + self, temp_minify_json, monkeypatch + ): + """A state class created after the resolver is installed gets its + minified name at registration time — no later refresh needed. + + This is the path exercised by ``ComponentState.create()`` and any + locally-defined state inside a page function: the class doesn't + exist when ``minify.json`` is loaded, so the resolver must be + consulted lazily on first lookup. + """ + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + late_path = "tests.units.test_minification.State.LateBornState" + _install_config(states={late_path: "lb"}) + + class LateBornState(State): + pass + + ctx = RegistrationContext.get() + assert LateBornState.get_name() == "lb" + # State stays un-minified, so the parent prefix is its default snake form. + assert ctx.base_states.get(f"{State.get_full_name()}.lb") is LateBornState + + +class TestMinifyModeEnvVars: + """Tests for REFLEX_MINIFY_STATES and REFLEX_MINIFY_EVENTS env vars.""" + + @pytest.mark.parametrize("var", ["REFLEX_MINIFY_STATES", "REFLEX_MINIFY_EVENTS"]) + def test_disabled_by_default(self, temp_minify_json, var): + """Both modes default to disabled even with a config present.""" + _install_config(states={"x": "a"}, events={"x": {"h": "a"}}) + assert is_mode_enabled(var) is False + + @pytest.mark.parametrize("var", ["REFLEX_MINIFY_STATES", "REFLEX_MINIFY_EVENTS"]) + def test_enabled_requires_env_and_config(self, temp_minify_json, monkeypatch, var): + """Each mode flips True only when its env var is on AND a config exists.""" + monkeypatch.setenv(getattr(environment, var).name, MinifyMode.ENABLED.value) + clear_config_cache() + assert is_mode_enabled(var) is False # env on, no config + _install_config(states={"x": "a"}, events={"x": {"h": "a"}}) + assert is_mode_enabled(var) is True + + def test_modes_toggle_independently(self, temp_minify_json, monkeypatch): + """States can be on while events stay off (or vice versa).""" + _set_minify_modes( + monkeypatch, states=MinifyMode.ENABLED, events=MinifyMode.DISABLED + ) + _install_config(states={"x": "a"}, events={"x": {"h": "a"}}) + assert is_mode_enabled("REFLEX_MINIFY_STATES") is True + assert is_mode_enabled("REFLEX_MINIFY_EVENTS") is False + assert is_minify_enabled() is True + + def test_is_minify_enabled_false_when_both_disabled(self, temp_minify_json): + """Default (no env) → ``is_minify_enabled`` is False even with config.""" + _install_config(states={"x": "a"}, events={"x": {"h": "a"}}) + 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). + """ + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + + # Build State -> ParentClassSubstateCollision -> ChildClassSubstateCollision + # where both children minify to "b". Class names are deliberately unique + # so ``_handle_local_def`` doesn't append a numeric suffix. + class ParentClassSubstateCollision(State): + pass + + class ChildClassSubstateCollision(ParentClassSubstateCollision): + pass + + _install_config( + states={ + get_state_full_path(ParentClassSubstateCollision): "b", + get_state_full_path(ChildClassSubstateCollision): "b", + } + ) + + assert ParentClassSubstateCollision.get_name() == "b" + assert ChildClassSubstateCollision.get_name() == "b" + + state_prefix = State.get_full_name() + assert ChildClassSubstateCollision.get_full_name() == f"{state_prefix}.b.b" + + resolved = State.get_class_substate(f"{state_prefix}.b.b") + assert resolved is ChildClassSubstateCollision + + 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 + + _set_minify_modes(monkeypatch, states=MinifyMode.ENABLED) + + class ParentInstanceSubstateCollision(State): + pass + + class ChildInstanceSubstateCollision(ParentInstanceSubstateCollision): + @rx.event + def my_handler(self): + pass + + _install_config( + states={ + get_state_full_path(ParentInstanceSubstateCollision): "b", + get_state_full_path(ChildInstanceSubstateCollision): "b", + } + ) + + root = State(_reflex_internal_init=True) # type: ignore[call-arg] + + resolved = root.get_substate([State.get_name(), "b", "b"]) + assert type(resolved) is ChildInstanceSubstateCollision + + +class TestMinifyLookupCLI: + """Tests for the 'reflex minify lookup' CLI command.""" + + def test_lookup_resolves_minified_path(self, temp_minify_json, cli_runner): + """Test that lookup resolves a minified path to full state info.""" + from reflex.reflex import cli + + class AppState(State): + pass + + class ChildState(AppState): + pass + + _install_config( + states={ + get_state_full_path(AppState): "b", + get_state_full_path(ChildState): "c", + }, + include_state_root=True, + ) + + result = cli_runner.invoke(cli, ["minify", "lookup", "a.b.c"]) + + assert result.exit_code == 0, result.output + 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, cli_runner): + """Test that lookup fails gracefully when minify.json is missing.""" + from reflex.reflex import cli + + clear_config_cache() + result = cli_runner.invoke(cli, ["minify", "lookup", "a.b"]) + + assert result.exit_code == 1 + assert "minify.json does not exist" in result.output + + def test_lookup_fails_for_malformed_config( + self, temp_minify_json: Path, cli_runner: CliRunner + ) -> None: + """Test that a malformed minify.json exits cleanly, not with a traceback.""" + from reflex.reflex import cli + + (temp_minify_json / "minify.json").write_text("{not json", encoding="utf-8") + clear_config_cache() + + result = cli_runner.invoke(cli, ["minify", "lookup", "a.b"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "Invalid JSON" in result.output + + def test_lookup_fails_for_invalid_path(self, temp_minify_json, cli_runner): + """Test that lookup fails for non-existent minified path.""" + from reflex.reflex import cli + + _install_config(include_state_root=True) + result = cli_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, cli_runner): + """Test that lookup with --json flag outputs valid JSON.""" + from reflex.reflex import cli + + class JsonTestState(State): + pass + + _install_config( + states={get_state_full_path(JsonTestState): "b"}, + include_state_root=True, + ) + + result = cli_runner.invoke(cli, ["minify", "lookup", "--json", "a.b"]) + + assert result.exit_code == 0, result.output + 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" + + +class TestNameResolverProtocol: + """Tests for the pluggable :class:`NameResolver` protocol. + + These exercise the contract independent of minification: any resolver that + implements ``resolve_state_name`` / ``resolve_handler_name`` can be slotted + into the registration context to rewrite names. + """ + + def test_default_resolver_returns_none(self): + """The default resolver yields no overrides.""" + resolver = DefaultNameResolver() + assert resolver.resolve_state_name(State) is None + assert resolver.resolve_handler_name(State, "any_handler") is None + + def test_default_resolver_satisfies_protocol(self): + """``DefaultNameResolver`` is a structural :class:`NameResolver`.""" + assert isinstance(DefaultNameResolver(), NameResolver) + + def test_minify_resolver_satisfies_protocol(self): + """``MinifyNameResolver`` is a structural :class:`NameResolver`.""" + resolver = MinifyNameResolver( + config=None, states_enabled=False, events_enabled=False + ) + assert isinstance(resolver, NameResolver) + + def test_get_state_name_falls_back_to_default(self): + """``RegistrationContext.get_state_name`` returns the built-in name when + the resolver returns None (the default). + """ + ctx = RegistrationContext.get() + assert ctx.get_state_name(State) == RegistrationContext.default_state_name( + State + ) + + def test_get_handler_name_falls_back_to_default(self): + """``RegistrationContext.get_handler_name`` returns the input name when + the resolver returns None (the default). + """ + ctx = RegistrationContext.get() + assert ctx.get_handler_name(State, "some_handler") == "some_handler" + + def test_set_name_resolver_propagates_through_get_name(self): + """A custom resolver swaps ``BaseState.get_name`` for the targeted class.""" + with _temporary_resolver(_stub_resolver(state_name="fixed_name")): + assert State.get_name() == "fixed_name" + + def test_set_name_resolver_propagates_through_format_event_handler(self): + """A custom resolver swaps the formatted handler name.""" + from reflex.state import OnLoadInternalState + from reflex.utils.format import format_event_handler + + with _temporary_resolver(_stub_resolver(handler_prefix="px_")): + formatted = format_event_handler(OnLoadInternalState.on_load_internal) # pyright: ignore[reportArgumentType] + assert formatted.endswith(".px_on_load_internal") + + def test_resolver_swap_clears_lru_caches(self): + """``set_name_resolver`` invalidates per-class name caches immediately.""" + with _temporary_resolver(_stub_resolver(state_name="first")) as ctx: + assert State.get_full_name() == "first" + ctx.set_name_resolver(_stub_resolver(state_name="second")) + assert State.get_full_name() == "second" + + def test_chain_of_resolvers(self): + """Resolvers compose with a tiny user-written chain wrapper.""" + + class Chain: + """Returns the first non-None override from the wrapped resolvers.""" + + def __init__(self, *resolvers): + self.resolvers = resolvers + + def resolve_state_name(self, state_cls): + for r in self.resolvers: + v = r.resolve_state_name(state_cls) + if v is not None: + return v + return None + + def resolve_handler_name(self, state_cls, handler_name): + for r in self.resolvers: + v = r.resolve_handler_name(state_cls, handler_name) + if v is not None: + return v + return None + + chain = Chain(_stub_resolver(state_name="from_first"), DefaultNameResolver()) + with _temporary_resolver(chain): + assert State.get_name() == "from_first" + + +class TestMinifyNameResolver: + """Tests for :class:`MinifyNameResolver` itself (config + caching).""" + + def test_disabled_returns_none(self, temp_minify_json): + """When neither flag is enabled, the resolver returns None for all.""" + resolver = MinifyNameResolver( + config={"version": SCHEMA_VERSION, "states": {}, "events": {}}, + states_enabled=False, + events_enabled=False, + ) + assert resolver.resolve_state_name(State) is None + assert resolver.resolve_handler_name(State, "any") is None + + def test_no_config_returns_none(self): + """No config means no overrides even when flags are enabled.""" + resolver = MinifyNameResolver( + config=None, states_enabled=True, events_enabled=True + ) + assert resolver.resolve_state_name(State) is None + assert resolver.resolve_handler_name(State, "any") is None + + def test_state_lookup_caches(self): + """Resolved state names are memoized after the first lookup.""" + + # Non-framework state — see :func:`_is_framework_state`. + class UserStateResolverCacheTest(State): + pass + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + get_state_full_path(UserStateResolverCacheTest): StateEntry( + id="rs", parent=None + ) + }, + "events": {}, + } + resolver = MinifyNameResolver( + config=config, states_enabled=True, events_enabled=False + ) + assert resolver.resolve_state_name(UserStateResolverCacheTest) == "rs" + # second call hits the cache + assert UserStateResolverCacheTest in resolver._state_cache + assert resolver.resolve_state_name(UserStateResolverCacheTest) == "rs" + + def test_event_lookup_caches(self): + """Resolved handler names are memoized per state class.""" + + class UserStateEventCacheTest(State): + pass + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": {}, + "events": { + get_state_full_path(UserStateEventCacheTest): {"foo": "f", "bar": "b"} + }, + } + resolver = MinifyNameResolver( + config=config, states_enabled=False, events_enabled=True + ) + assert resolver.resolve_handler_name(UserStateEventCacheTest, "foo") == "f" + assert resolver.resolve_handler_name(UserStateEventCacheTest, "bar") == "b" + assert resolver.resolve_handler_name(UserStateEventCacheTest, "missing") is None + assert UserStateEventCacheTest in resolver._event_cache + + def test_from_disk_handles_malformed_config(self, temp_minify_json): + """``from_disk`` returns a usable resolver even when minify.json is bad.""" + path = temp_minify_json / MINIFY_JSON + with path.open("w") as f: + f.write("{not valid json") + resolver = MinifyNameResolver.from_disk() + # config falls back to None — every lookup returns None. + assert resolver.config is None + assert resolver.resolve_state_name(State) is None + assert resolver.resolve_handler_name(State, "any") is None + + +class TestImportTimeInstall: + """Importing reflex.state must install the resolver before states register.""" + + def test_resolver_active_before_any_state_registers(self, tmp_path): + """A fresh interpreter registers user states under their minified name.""" + import os + import subprocess + import sys + import textwrap + + config: MinifyConfig = { + "version": SCHEMA_VERSION, + "states": { + "myapp.State.Foo": StateEntry(id="f", parent="reflex.state.State") + }, + "events": {}, + } + (tmp_path / MINIFY_JSON).write_text(json.dumps(config)) + (tmp_path / "myapp.py").write_text( + textwrap.dedent(""" + from reflex_base.registry import RegistrationContext + import reflex.state + resolver = RegistrationContext.ensure_context().name_resolver + assert type(resolver).__name__ == "MinifyNameResolver", resolver + class Foo(reflex.state.State): + pass + assert Foo.get_name() == "f", Foo.get_name() + """) + ) + + result = subprocess.run( + [sys.executable, "-c", "import myapp"], + cwd=tmp_path, + env={**os.environ, "REFLEX_MINIFY_STATES": MinifyMode.ENABLED.value}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr