From 15f363b9a7fa93110367286f6c212b0dff0927c7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 00:01:22 +0500 Subject: [PATCH 01/31] Add compiler plugin hooks and move compilation pipeline out of App Move the frontend compilation pipeline from App._compile into compiler.compile_app(), introducing a CompilerPlugin protocol with enter_component/leave_component/eval_page/compile_page hooks. Remove the ExecutorType/ExecutorSafeFunctions abstractions in favor of a sequential plugin-driven compilation model. --- .../src/reflex_core/components/component.py | 3 +- .../src/reflex_core/environment.py | 104 +- .../src/reflex_core/plugins/__init__.py | 16 + .../src/reflex_core/plugins/base.py | 91 +- .../src/reflex_core/plugins/compiler.py | 1115 +++++++++++++++++ .../src/reflex_core/utils/console.py | 12 + pyi_hashes.json | 119 -- reflex/app.py | 433 +------ reflex/compiler/compiler.py | 447 +++++-- reflex/compiler/plugins/__init__.py | 32 + reflex/compiler/plugins/builtin.py | 537 ++++++++ reflex/plugins/__init__.py | 14 + tests/units/compiler/test_plugins.py | 822 ++++++++++++ tests/units/test_environment.py | 42 - 14 files changed, 3030 insertions(+), 757 deletions(-) create mode 100644 packages/reflex-core/src/reflex_core/plugins/compiler.py create mode 100644 reflex/compiler/plugins/__init__.py create mode 100644 reflex/compiler/plugins/builtin.py create mode 100644 tests/units/compiler/test_plugins.py diff --git a/packages/reflex-core/src/reflex_core/components/component.py b/packages/reflex-core/src/reflex_core/components/component.py index 82d60203b22..f17516d3b39 100644 --- a/packages/reflex-core/src/reflex_core/components/component.py +++ b/packages/reflex-core/src/reflex_core/components/component.py @@ -1556,6 +1556,7 @@ def _iter_parent_classes_names(cls) -> Iterator[str]: yield clz.__name__ @classmethod + @functools.cache def _iter_parent_classes_with_method(cls, method: str) -> Sequence[type[Component]]: """Iterate through parent classes that define a given method. @@ -1582,7 +1583,7 @@ def _iter_parent_classes_with_method(cls, method: str) -> Sequence[type[Componen continue seen_methods.add(method_func) clzs.append(clz) - return clzs + return tuple(clzs) def _get_custom_code(self) -> str | None: """Get custom code for the component. diff --git a/packages/reflex-core/src/reflex_core/environment.py b/packages/reflex-core/src/reflex_core/environment.py index a747cd21ba1..f005dd41ff3 100644 --- a/packages/reflex-core/src/reflex_core/environment.py +++ b/packages/reflex-core/src/reflex_core/environment.py @@ -2,14 +2,11 @@ from __future__ import annotations -import concurrent.futures import dataclasses import enum import importlib -import multiprocessing import os -import platform -from collections.abc import Callable, Sequence +from collections.abc import Sequence from functools import lru_cache from pathlib import Path from typing import ( @@ -529,97 +526,6 @@ class PerformanceMode(enum.Enum): OFF = "off" -class ExecutorType(enum.Enum): - """Executor for compiling the frontend.""" - - THREAD = "thread" - PROCESS = "process" - MAIN_THREAD = "main_thread" - - @classmethod - def get_executor_from_environment(cls): - """Get the executor based on the environment variables. - - Returns: - The executor. - """ - from reflex_core.utils import console - - executor_type = environment.REFLEX_COMPILE_EXECUTOR.get() - - reflex_compile_processes = environment.REFLEX_COMPILE_PROCESSES.get() - reflex_compile_threads = environment.REFLEX_COMPILE_THREADS.get() - # By default, use the main thread. Unless the user has specified a different executor. - # Using a process pool is much faster, but not supported on all platforms. It's gated behind a flag. - if executor_type is None: - if ( - platform.system() not in ("Linux", "Darwin") - and reflex_compile_processes is not None - ): - console.warn("Multiprocessing is only supported on Linux and MacOS.") - - if ( - platform.system() in ("Linux", "Darwin") - and reflex_compile_processes is not None - ): - if reflex_compile_processes == 0: - console.warn( - "Number of processes must be greater than 0. If you want to use the default number of processes, set REFLEX_COMPILE_EXECUTOR to 'process'. Defaulting to None." - ) - reflex_compile_processes = None - elif reflex_compile_processes < 0: - console.warn( - "Number of processes must be greater than 0. Defaulting to None." - ) - reflex_compile_processes = None - executor_type = ExecutorType.PROCESS - elif reflex_compile_threads is not None: - if reflex_compile_threads == 0: - console.warn( - "Number of threads must be greater than 0. If you want to use the default number of threads, set REFLEX_COMPILE_EXECUTOR to 'thread'. Defaulting to None." - ) - reflex_compile_threads = None - elif reflex_compile_threads < 0: - console.warn( - "Number of threads must be greater than 0. Defaulting to None." - ) - reflex_compile_threads = None - executor_type = ExecutorType.THREAD - else: - executor_type = ExecutorType.MAIN_THREAD - - match executor_type: - case ExecutorType.PROCESS: - executor = concurrent.futures.ProcessPoolExecutor( - max_workers=reflex_compile_processes, - mp_context=multiprocessing.get_context("fork"), - ) - case ExecutorType.THREAD: - executor = concurrent.futures.ThreadPoolExecutor( - max_workers=reflex_compile_threads - ) - case ExecutorType.MAIN_THREAD: - FUTURE_RESULT_TYPE = TypeVar("FUTURE_RESULT_TYPE") - - class MainThreadExecutor: - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - def submit( - self, fn: Callable[..., FUTURE_RESULT_TYPE], *args, **kwargs - ) -> concurrent.futures.Future[FUTURE_RESULT_TYPE]: - future_job = concurrent.futures.Future() - future_job.set_result(fn(*args, **kwargs)) - return future_job - - executor = MainThreadExecutor() - - return executor - - class EnvironmentVariables: """Environment variables class to instantiate environment variables.""" @@ -660,14 +566,6 @@ class EnvironmentVariables: Path(constants.Dirs.UPLOADED_FILES) ) - REFLEX_COMPILE_EXECUTOR: EnvVar[ExecutorType | None] = env_var(None) - - # Whether to use separate processes to compile the frontend and how many. If not set, defaults to thread executor. - REFLEX_COMPILE_PROCESSES: EnvVar[int | None] = env_var(None) - - # Whether to use separate threads to compile the frontend and how many. Defaults to `min(32, os.cpu_count() + 4)`. - REFLEX_COMPILE_THREADS: EnvVar[int | None] = env_var(None) - # The directory to store reflex dependencies. REFLEX_DIR: EnvVar[Path] = env_var(constants.Reflex.DIR) diff --git a/packages/reflex-core/src/reflex_core/plugins/__init__.py b/packages/reflex-core/src/reflex_core/plugins/__init__.py index 754409046b8..e59a2a0737e 100644 --- a/packages/reflex-core/src/reflex_core/plugins/__init__.py +++ b/packages/reflex-core/src/reflex_core/plugins/__init__.py @@ -2,12 +2,28 @@ from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin from .base import CommonContext, Plugin, PreCompileContext +from .compiler import ( + BaseContext, + CompileContext, + CompilerHooks, + CompilerPlugin, + ComponentAndChildren, + PageContext, + PageDefinition, +) from .sitemap import SitemapPlugin from .tailwind_v3 import TailwindV3Plugin from .tailwind_v4 import TailwindV4Plugin __all__ = [ + "BaseContext", "CommonContext", + "CompileContext", + "CompilerHooks", + "CompilerPlugin", + "ComponentAndChildren", + "PageContext", + "PageDefinition", "Plugin", "PreCompileContext", "SitemapPlugin", diff --git a/packages/reflex-core/src/reflex_core/plugins/base.py b/packages/reflex-core/src/reflex_core/plugins/base.py index 52dfa8d7805..d8d5bdffd69 100644 --- a/packages/reflex-core/src/reflex_core/plugins/base.py +++ b/packages/reflex-core/src/reflex_core/plugins/base.py @@ -2,12 +2,14 @@ from collections.abc import Callable, Sequence from pathlib import Path -from typing import TYPE_CHECKING, ParamSpec, Protocol, TypedDict +from typing import TYPE_CHECKING, Any, ParamSpec, Protocol, TypedDict from typing_extensions import Unpack if TYPE_CHECKING: from reflex.app import App, UnevaluatedPage + from reflex_core.components.component import BaseComponent, StatefulComponent + from reflex_core.plugins.compiler import ComponentAndChildren, PageContext class CommonContext(TypedDict): @@ -117,6 +119,93 @@ def post_compile(self, **context: Unpack[PostCompileContext]) -> None: context: The context for the plugin. """ + def eval_page( + self, + page_fn: Any, + /, + **kwargs: Any, + ) -> "PageContext | None": + """Evaluate a page-like object into a page context. + + Args: + page_fn: The page-like object to evaluate. + kwargs: Additional compiler-specific context. + + Returns: + A page context when the plugin can evaluate the page, otherwise ``None``. + """ + del page_fn, kwargs + return None + + def compile_page( + self, + page_ctx: "PageContext", + /, + **kwargs: Any, + ) -> None: + """Finalize a page context after its component tree has been traversed.""" + del page_ctx, kwargs + return + + def enter_component( + self, + comp: "BaseComponent", + /, + *, + page_context: "PageContext", + compile_context: Any, + in_prop_tree: bool = False, + stateful_component: "StatefulComponent | None" = None, + ) -> "BaseComponent | ComponentAndChildren | None": + """Inspect or transform a component before visiting its descendants. + + Args: + comp: The component being compiled. + page_context: The active page compilation state. + compile_context: The active compile-run state. + in_prop_tree: Whether the component is being visited through a prop subtree. + stateful_component: The surrounding stateful component, when applicable. + + Returns: + An optional replacement component and/or structural children. + """ + del comp, page_context, compile_context, in_prop_tree, stateful_component + return None + + def leave_component( + self, + comp: "BaseComponent", + children: tuple["BaseComponent", ...], + /, + *, + page_context: "PageContext", + compile_context: Any, + in_prop_tree: bool = False, + stateful_component: "StatefulComponent | None" = None, + ) -> "BaseComponent | ComponentAndChildren | None": + """Inspect or transform a component after visiting its descendants. + + Args: + comp: The component being compiled. + children: The compiled structural children for the component. + page_context: The active page compilation state. + compile_context: The active compile-run state. + in_prop_tree: Whether the component is being visited through a prop subtree. + stateful_component: The surrounding stateful component, when applicable. + + Returns: + An optional replacement component and/or structural children. + """ + del ( + comp, + children, + page_context, + compile_context, + in_prop_tree, + stateful_component, + ) + return None + def __repr__(self): """Return a string representation of the plugin. diff --git a/packages/reflex-core/src/reflex_core/plugins/compiler.py b/packages/reflex-core/src/reflex_core/plugins/compiler.py new file mode 100644 index 00000000000..13471eb2482 --- /dev/null +++ b/packages/reflex-core/src/reflex_core/plugins/compiler.py @@ -0,0 +1,1115 @@ +"""Compiler plugin infrastructure: protocols, contexts, and dispatch.""" + +from __future__ import annotations + +import dataclasses +import inspect +from collections.abc import Callable, Sequence +from contextvars import ContextVar, Token +from types import TracebackType +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, cast + +from typing_extensions import Self + +from reflex_core.components.component import BaseComponent, Component, StatefulComponent +from reflex_core.utils.imports import ParsedImportDict, collapse_imports, merge_imports +from reflex_core.vars import VarData + +from .base import Plugin + +if TYPE_CHECKING: + from reflex.app import App, ComponentCallable + + PageComponent: TypeAlias = Component | ComponentCallable +else: + PageComponent: TypeAlias = ( + Component + | Callable[ + [], + Component | tuple[Component, ...] | str, + ] + ) + + +class PageDefinition(Protocol): + """Protocol for page-like objects compiled by :class:`CompileContext`.""" + + @property + def route(self) -> str: + """Return the route for this page definition.""" + ... + + @property + def component(self) -> PageComponent: + """Return the component or callable for this page definition.""" + ... + + +ComponentAndChildren: TypeAlias = tuple[BaseComponent, tuple[BaseComponent, ...]] +ComponentReplacement: TypeAlias = BaseComponent | ComponentAndChildren | None +CompiledEnterHook: TypeAlias = Callable[ + [BaseComponent, bool, StatefulComponent | None], + ComponentReplacement, +] +CompiledLeaveHook: TypeAlias = Callable[ + [BaseComponent, tuple[BaseComponent, ...], bool, StatefulComponent | None], + ComponentReplacement, +] +EnterHookBinder: TypeAlias = Callable[ + ["PageContext", "CompileContext"], + CompiledEnterHook, +] +LeaveHookBinder: TypeAlias = Callable[ + ["PageContext", "CompileContext"], + CompiledLeaveHook, +] + + +class CompilerPlugin(Protocol): + """Protocol for compiler plugins that participate in page compilation.""" + + def eval_page( + self, + page_fn: PageComponent, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> PageContext | None: + """Evaluate a page-like object into a page context. + + Args: + page_fn: The page-like object to evaluate. + page: The page definition being compiled. + kwargs: Additional compiler-specific context. + + Returns: + A page context when the plugin can evaluate the page, otherwise ``None``. + """ + return None + + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + """Finalize a page context after its component tree has been traversed.""" + return + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> ComponentReplacement: + """Inspect or transform a component before visiting its descendants. + + Args: + comp: The component being compiled. + page_context: The active page compilation state. + compile_context: The active compile-run state. + in_prop_tree: Whether the component belongs to a prop subtree. + stateful_component: The active surrounding stateful component. + + Returns: + An optional replacement component and/or structural children. + """ + del comp, page_context, compile_context, in_prop_tree, stateful_component + return None + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> ComponentReplacement: + """Inspect or transform a component after visiting its descendants. + + Args: + comp: The component being compiled. + children: The compiled structural children for the component. + page_context: The active page compilation state. + compile_context: The active compile-run state. + in_prop_tree: Whether the component belongs to a prop subtree. + stateful_component: The active surrounding stateful component. + + Returns: + An optional replacement component and/or structural children. + """ + del ( + comp, + children, + page_context, + compile_context, + in_prop_tree, + stateful_component, + ) + return None + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class CompilerHooks: + """Dispatch compiler hooks across an ordered plugin chain.""" + + plugins: tuple[CompilerPlugin, ...] = () + _eval_page_hooks: tuple[Callable[..., Any], ...] = dataclasses.field( + init=False, + repr=False, + ) + _compile_page_hooks: tuple[Callable[..., Any], ...] = dataclasses.field( + init=False, + repr=False, + ) + _enter_component_hooks: tuple[Callable[..., Any], ...] = dataclasses.field( + init=False, + repr=False, + ) + _leave_component_hooks: tuple[tuple[Callable[..., Any], bool], ...] = ( + dataclasses.field( + init=False, + repr=False, + ) + ) + _enter_component_hook_binders: tuple[EnterHookBinder, ...] = dataclasses.field( + init=False, + repr=False, + ) + _leave_component_hook_binders: tuple[tuple[LeaveHookBinder, bool], ...] = ( + dataclasses.field( + init=False, + repr=False, + ) + ) + _regular_leave_component_hook_binders: tuple[LeaveHookBinder, ...] = ( + dataclasses.field( + init=False, + repr=False, + ) + ) + _stateful_leave_component_hook_binders: tuple[LeaveHookBinder, ...] = ( + dataclasses.field( + init=False, + repr=False, + ) + ) + _component_hooks_can_replace: bool = dataclasses.field( + init=False, + repr=False, + ) + + def __post_init__(self) -> None: + """Resolve the active compiler hook callables once.""" + object.__setattr__(self, "_eval_page_hooks", self._resolve_hooks("eval_page")) + object.__setattr__( + self, + "_compile_page_hooks", + self._resolve_hooks("compile_page"), + ) + enter_hooks: list[Callable[..., Any]] = [] + enter_hook_binders: list[EnterHookBinder] = [] + leave_hooks: list[tuple[Callable[..., Any], bool]] = [] + leave_hook_binders: list[tuple[LeaveHookBinder, bool]] = [] + component_hooks_can_replace = False + + for plugin in self.plugins: + if ( + hook_impl := self._get_hook_impl(plugin, "enter_component") + ) is not None: + enter_hooks.append(hook_impl) + enter_hook_binders.append( + self._get_enter_hook_binder(plugin, hook_impl) + ) + component_hooks_can_replace = component_hooks_can_replace or bool( + getattr( + type(plugin), + "_compiler_can_replace_enter_component", + True, + ) + ) + + if ( + hook_impl := self._get_hook_impl(plugin, "leave_component") + ) is not None: + stateful_only = bool( + getattr( + type(plugin), + "_compiler_stateful_only_leave_component", + False, + ) + ) + leave_hooks.append((hook_impl, stateful_only)) + leave_hook_binders.append(( + self._get_leave_hook_binder(plugin, hook_impl), + stateful_only, + )) + component_hooks_can_replace = component_hooks_can_replace or bool( + getattr( + type(plugin), + "_compiler_can_replace_leave_component", + True, + ) + ) + + reversed_leave_hooks = tuple(reversed(tuple(leave_hooks))) + reversed_leave_hook_binders = tuple(reversed(tuple(leave_hook_binders))) + object.__setattr__( + self, + "_leave_component_hooks", + reversed_leave_hooks, + ) + object.__setattr__( + self, + "_enter_component_hooks", + tuple(enter_hooks), + ) + object.__setattr__( + self, + "_enter_component_hook_binders", + tuple(enter_hook_binders), + ) + object.__setattr__( + self, + "_leave_component_hook_binders", + reversed_leave_hook_binders, + ) + object.__setattr__( + self, + "_regular_leave_component_hook_binders", + tuple( + binder + for binder, stateful_only in reversed_leave_hook_binders + if not stateful_only + ), + ) + object.__setattr__( + self, + "_stateful_leave_component_hook_binders", + tuple( + binder + for binder, stateful_only in reversed_leave_hook_binders + if stateful_only + ), + ) + object.__setattr__( + self, + "_component_hooks_can_replace", + component_hooks_can_replace, + ) + + @staticmethod + def _get_hook_impl( + plugin: CompilerPlugin, + hook_name: str, + ) -> Callable[..., Any] | None: + """Return the concrete hook implementation for a plugin, if any. + + Args: + plugin: The plugin to inspect. + hook_name: The hook attribute name. + + Returns: + The bound hook implementation, or ``None`` when the hook is inherited + unchanged from the default base implementation. + """ + plugin_impl = inspect.getattr_static(type(plugin), hook_name, None) + if plugin_impl is None: + return None + + for base_cls in (CompilerPlugin, Plugin): + base_impl = inspect.getattr_static(base_cls, hook_name, None) + if plugin_impl is base_impl: + return None + + return cast(Callable[..., Any], getattr(plugin, hook_name, None)) + + def _resolve_hooks(self, hook_name: str) -> tuple[Callable[..., Any], ...]: + """Resolve concrete hook implementations for the plugin chain. + + Args: + hook_name: The hook attribute name. + + Returns: + The ordered concrete hook implementations for the hook. + """ + return tuple( + hook_impl + for plugin in self.plugins + if (hook_impl := self._get_hook_impl(plugin, hook_name)) is not None + ) + + @staticmethod + def _get_enter_hook_binder( + plugin: CompilerPlugin, + hook_impl: Callable[..., Any], + ) -> EnterHookBinder: + """Return a binder that produces a compiled enter-component hook.""" + if ( + binder := getattr(plugin, "_compiler_bind_enter_component", None) + ) is not None: + return cast(EnterHookBinder, binder) + + def bind( + page_context: PageContext, compile_context: CompileContext + ) -> CompiledEnterHook: + def enter_component( + comp: BaseComponent, + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> ComponentReplacement: + return cast( + ComponentReplacement, + hook_impl( + comp, + page_context=page_context, + compile_context=compile_context, + in_prop_tree=in_prop_tree, + stateful_component=stateful_component, + ), + ) + + return enter_component + + return bind + + @staticmethod + def _get_leave_hook_binder( + plugin: CompilerPlugin, + hook_impl: Callable[..., Any], + ) -> LeaveHookBinder: + """Return a binder that produces a compiled leave-component hook.""" + if ( + binder := getattr(plugin, "_compiler_bind_leave_component", None) + ) is not None: + return cast(LeaveHookBinder, binder) + + def bind( + page_context: PageContext, compile_context: CompileContext + ) -> CompiledLeaveHook: + def leave_component( + comp: BaseComponent, + children: tuple[BaseComponent, ...], + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> ComponentReplacement: + return cast( + ComponentReplacement, + hook_impl( + comp, + children, + page_context=page_context, + compile_context=compile_context, + in_prop_tree=in_prop_tree, + stateful_component=stateful_component, + ), + ) + + return leave_component + + return bind + + def eval_page( + self, + page_fn: PageComponent, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> PageContext | None: + """Return the first page context produced by the plugin chain.""" + for hook_impl in self._eval_page_hooks: + result = hook_impl(page_fn, page=page, **kwargs) + if result is not None: + return cast(PageContext, result) + return None + + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + """Run all ``compile_page`` hooks in plugin order.""" + for hook_impl in self._compile_page_hooks: + hook_impl(page_ctx, **kwargs) + + def compile_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent: + """Walk a component tree once while dispatching cached enter/leave hooks. + + Returns: + The compiled component root for this subtree. + """ + enter_hooks = tuple( + hook_binder(page_context, compile_context) + for hook_binder in self._enter_component_hook_binders + ) + + if not self._component_hooks_can_replace: + regular_leave_hooks = tuple( + hook_binder(page_context, compile_context) + for hook_binder in self._regular_leave_component_hook_binders + ) + stateful_leave_hooks = tuple( + hook_binder(page_context, compile_context) + for hook_binder in self._stateful_leave_component_hook_binders + ) + + if ( + len(enter_hooks) == 1 + and not regular_leave_hooks + and len(stateful_leave_hooks) <= 1 + ): + return self._compile_component_single_enter_fast_path( + comp, + enter_hook=enter_hooks[0], + stateful_leave_hook=( + stateful_leave_hooks[0] if stateful_leave_hooks else None + ), + in_prop_tree=in_prop_tree, + stateful_component=stateful_component, + ) + + return self._compile_component_without_replacements( + comp, + enter_hooks=enter_hooks, + regular_leave_hooks=regular_leave_hooks, + stateful_leave_hooks=stateful_leave_hooks, + in_prop_tree=in_prop_tree, + stateful_component=stateful_component, + ) + + return self._compile_component_with_replacements( + comp, + enter_hooks=enter_hooks, + leave_hooks=tuple( + (hook_binder(page_context, compile_context), stateful_only) + for hook_binder, stateful_only in self._leave_component_hook_binders + ), + in_prop_tree=in_prop_tree, + stateful_component=stateful_component, + ) + + def _compile_component_without_replacements( + self, + comp: BaseComponent, + /, + *, + enter_hooks: tuple[CompiledEnterHook, ...], + regular_leave_hooks: tuple[CompiledLeaveHook, ...], + stateful_leave_hooks: tuple[CompiledLeaveHook, ...], + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent: + """Walk a component tree when hook plans only observe state. + + Returns: + The compiled component root for this subtree. + """ + + def visit( + current_comp: BaseComponent, + current_in_prop_tree: bool, + current_stateful_component: StatefulComponent | None, + ) -> BaseComponent: + for hook_impl in enter_hooks: + hook_impl( + current_comp, + current_in_prop_tree, + current_stateful_component, + ) + + if isinstance(current_comp, StatefulComponent): + if not current_comp.rendered_as_shared: + compiled_component = cast( + Component, + visit( + current_comp.component, + current_in_prop_tree, + current_comp, + ), + ) + if compiled_component is not current_comp.component: + current_comp.component = compiled_component + + if stateful_leave_hooks: + compiled_children = tuple(current_comp.children) + for hook_impl in stateful_leave_hooks: + hook_impl( + current_comp, + compiled_children, + current_in_prop_tree, + current_stateful_component, + ) + if regular_leave_hooks: + compiled_children = tuple(current_comp.children) + for hook_impl in regular_leave_hooks: + hook_impl( + current_comp, + compiled_children, + current_in_prop_tree, + current_stateful_component, + ) + return current_comp + + updated_children: list[BaseComponent] | None = None + children = current_comp.children + for index, child in enumerate(children): + compiled_child = visit( + child, + current_in_prop_tree, + current_stateful_component, + ) + if updated_children is None: + if compiled_child is child: + continue + updated_children = list(children[:index]) + updated_children.append(compiled_child) + if updated_children is not None: + current_comp.children = updated_children + + if isinstance(current_comp, Component): + for prop_component in current_comp._get_components_in_props(): + visit( + prop_component, + True, + current_stateful_component, + ) + + if regular_leave_hooks: + compiled_children = tuple(current_comp.children) + for hook_impl in regular_leave_hooks: + hook_impl( + current_comp, + compiled_children, + current_in_prop_tree, + current_stateful_component, + ) + + return current_comp + + return visit( + comp, + in_prop_tree, + stateful_component, + ) + + def _compile_component_single_enter_fast_path( + self, + comp: BaseComponent, + /, + *, + enter_hook: CompiledEnterHook, + stateful_leave_hook: CompiledLeaveHook | None, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent: + """Walk a component tree for the common one-enter-hook fast path. + + Returns: + The compiled component root for this subtree. + """ + + def visit( + current_comp: BaseComponent, + current_in_prop_tree: bool, + current_stateful_component: StatefulComponent | None, + ) -> BaseComponent: + enter_hook( + current_comp, + current_in_prop_tree, + current_stateful_component, + ) + + if isinstance(current_comp, StatefulComponent): + if not current_comp.rendered_as_shared: + compiled_component = cast( + Component, + visit( + current_comp.component, + current_in_prop_tree, + current_comp, + ), + ) + if compiled_component is not current_comp.component: + current_comp.component = compiled_component + + if stateful_leave_hook is not None: + stateful_leave_hook( + current_comp, + tuple(current_comp.children), + current_in_prop_tree, + current_stateful_component, + ) + return current_comp + + updated_children: list[BaseComponent] | None = None + children = current_comp.children + for index, child in enumerate(children): + compiled_child = visit( + child, + current_in_prop_tree, + current_stateful_component, + ) + if updated_children is None: + if compiled_child is child: + continue + updated_children = list(children[:index]) + updated_children.append(compiled_child) + if updated_children is not None: + current_comp.children = updated_children + + if isinstance(current_comp, Component): + for prop_component in current_comp._get_components_in_props(): + visit( + prop_component, + True, + current_stateful_component, + ) + + return current_comp + + return visit( + comp, + in_prop_tree, + stateful_component, + ) + + def _compile_component_with_replacements( + self, + comp: BaseComponent, + /, + *, + enter_hooks: tuple[CompiledEnterHook, ...], + leave_hooks: tuple[tuple[CompiledLeaveHook, bool], ...], + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent: + """Walk a component tree while honoring hook replacements. + + Returns: + The compiled component root for this subtree. + """ + apply_replacement = self._apply_replacement + + def visit_children( + children: Sequence[BaseComponent], + current_in_prop_tree: bool, + current_stateful_component: StatefulComponent | None, + ) -> tuple[BaseComponent, ...]: + if not children: + return () + + updated_children: list[BaseComponent] | None = None + for index, child in enumerate(children): + compiled_child = visit( + child, + current_in_prop_tree, + current_stateful_component, + ) + if updated_children is None: + if compiled_child is child: + continue + updated_children = list(children[:index]) + updated_children.append(compiled_child) + if updated_children is None: + return children if isinstance(children, tuple) else tuple(children) + return tuple(updated_children) + + def visit( + current_comp: BaseComponent, + current_in_prop_tree: bool, + current_stateful_component: StatefulComponent | None, + ) -> BaseComponent: + compiled_component = current_comp + structural_children: tuple[BaseComponent, ...] | None = None + + for hook_impl in enter_hooks: + compiled_component, structural_children = apply_replacement( + compiled_component, + structural_children, + hook_impl( + compiled_component, + current_in_prop_tree, + current_stateful_component, + ), + ) + + if isinstance(compiled_component, StatefulComponent): + if not compiled_component.rendered_as_shared: + compiled_component.component = cast( + Component, + visit( + compiled_component.component, + current_in_prop_tree, + compiled_component, + ), + ) + compiled_children = tuple(compiled_component.children) + else: + if structural_children is None: + structural_children = tuple(compiled_component.children) + compiled_children = visit_children( + structural_children, + current_in_prop_tree, + current_stateful_component, + ) + if isinstance(compiled_component, Component): + for prop_component in compiled_component._get_components_in_props(): + visit( + prop_component, + True, + current_stateful_component, + ) + + is_stateful_component = isinstance(compiled_component, StatefulComponent) + for hook_impl, stateful_only in leave_hooks: + if stateful_only and not is_stateful_component: + continue + compiled_component, replacement_children = apply_replacement( + compiled_component, + compiled_children, + hook_impl( + compiled_component, + compiled_children, + current_in_prop_tree, + current_stateful_component, + ), + ) + if replacement_children is not compiled_children: + assert replacement_children is not None + compiled_children = visit_children( + replacement_children, + current_in_prop_tree, + current_stateful_component, + ) + + compiled_component.children = list(compiled_children) + return compiled_component + + return visit( + comp, + in_prop_tree, + stateful_component, + ) + + @staticmethod + def _apply_replacement( + comp: BaseComponent, + children: tuple[BaseComponent, ...] | None, + replacement: ComponentReplacement, + ) -> tuple[BaseComponent, tuple[BaseComponent, ...] | None]: + """Apply a plugin replacement to the current component state. + + Args: + comp: The current component. + children: The current structural children. + replacement: The plugin-supplied replacement. + + Returns: + The updated component and structural children pair. + """ + if replacement is None: + return comp, children + if isinstance(replacement, tuple): + return replacement + return replacement, children + + +@dataclasses.dataclass(kw_only=True) +class BaseContext: + """Context manager that exposes itself through a class-local context var.""" + + __context_var__: ClassVar[ContextVar[Self | None]] + + _attached_context_token: Token[Self | None] | None = dataclasses.field( + default=None, + init=False, + repr=False, + ) + + @classmethod + def __init_subclass__(cls, **kwargs: Any) -> None: + """Initialize a dedicated context variable for each subclass.""" + super().__init_subclass__(**kwargs) + cls.__context_var__ = ContextVar(cls.__name__, default=None) + + @classmethod + def get(cls) -> Self: + """Return the active context instance for the current task. + + Returns: + The active context instance for the current task. + """ + context = cls.__context_var__.get() + if context is None: + msg = f"No active {cls.__name__} is attached to the current context." + raise RuntimeError(msg) + return context + + def __enter__(self) -> Self: + """Attach this context to the current task. + + Returns: + The attached context instance. + """ + if self._attached_context_token is not None: + msg = "Context is already attached and cannot be entered twice." + raise RuntimeError(msg) + self._attached_context_token = type(self).__context_var__.set(self) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Detach this context from the current task.""" + del exc_type, exc_val, exc_tb + if self._attached_context_token is None: + return + try: + type(self).__context_var__.reset(self._attached_context_token) + finally: + self._attached_context_token = None + + async def __aenter__(self) -> Self: + """Attach this context to the current task asynchronously. + + Returns: + The attached context instance. + """ + return self.__enter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Detach this context from the current task asynchronously.""" + self.__exit__(exc_type, exc_val, exc_tb) + + def ensure_context_attached(self) -> None: + """Ensure this instance is the active context for the current task.""" + try: + current = type(self).get() + except RuntimeError as err: + msg = ( + f"{type(self).__name__} must be entered with 'with' or 'async with' " + "before calling this method." + ) + raise RuntimeError(msg) from err + if current is not self: + msg = f"{type(self).__name__} is not attached to the current task context." + raise RuntimeError(msg) + + +@dataclasses.dataclass(slots=True, kw_only=True) +class PageContext(BaseContext): + """Mutable compilation state for a single page.""" + + name: str + route: str + root_component: BaseComponent + imports: list[ParsedImportDict] = dataclasses.field(default_factory=list) + module_code: dict[str, None] = dataclasses.field(default_factory=dict) + hooks: dict[str, VarData | None] = dataclasses.field(default_factory=dict) + dynamic_imports: set[str] = dataclasses.field(default_factory=set) + refs: dict[str, None] = dataclasses.field(default_factory=dict) + app_wrap_components: dict[tuple[int, str], Component] = dataclasses.field( + default_factory=dict + ) + frontend_imports: ParsedImportDict = dataclasses.field(default_factory=dict) + output_path: str | None = None + output_code: str | None = None + + def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict: + """Return the imports accumulated for this page. + + Args: + collapse: Whether to collapse duplicate imports. + + Returns: + The merged page imports. + """ + imports = merge_imports(*self.imports) if self.imports else {} + return collapse_imports(imports) if collapse else imports + + def custom_code_dict(self) -> dict[str, None]: + """Return custom-code snippets keyed like legacy collectors. + + Returns: + The page custom code keyed by snippet. + """ + return dict(self.module_code) + + +@dataclasses.dataclass(slots=True, kw_only=True) +class CompileContext(BaseContext): + """Mutable compilation state for an entire compile run.""" + + app: App | None = None + pages: Sequence[PageDefinition] + hooks: CompilerHooks = dataclasses.field(default_factory=CompilerHooks) + compiled_pages: dict[str, PageContext] = dataclasses.field(default_factory=dict) + all_imports: ParsedImportDict = dataclasses.field(default_factory=dict) + app_wrap_components: dict[tuple[int, str], Component] = dataclasses.field( + default_factory=dict + ) + stateful_routes: dict[str, None] = dataclasses.field(default_factory=dict) + stateful_components_path: str | None = None + stateful_components_code: str = "" + + def compile( + self, + *, + evaluate_progress: Callable[[], None] | None = None, + render_progress: Callable[[], None] | None = None, + apply_overlay: bool = False, + **kwargs: Any, + ) -> dict[str, PageContext]: + """Compile all configured pages through the plugin pipeline. + + Args: + evaluate_progress: Callback invoked after each page evaluation. + render_progress: Callback invoked after each page render. + apply_overlay: Whether to apply the app overlay during evaluation. + kwargs: Additional compiler-specific context. + + Returns: + The compiled page contexts keyed by route. + """ + from reflex.compiler import compiler + from reflex.state import all_base_state_classes + from reflex.utils.exec import is_prod_mode + + self.ensure_context_attached() + self.compiled_pages.clear() + self.all_imports.clear() + self.app_wrap_components.clear() + self.stateful_routes.clear() + self.stateful_components_path = compiler.utils.get_stateful_components_path() + self.stateful_components_code = "" + + overlay_component: Component | None = None + if ( + apply_overlay + and self.app is not None + and self.app.overlay_component is not None + ): + overlay_component = self.app._generate_component(self.app.overlay_component) + + for page in self.pages: + page_fn = page.component + n_states_before = len(all_base_state_classes) + page_ctx = self.hooks.eval_page( + page_fn, + page=page, + compile_context=self, + **kwargs, + ) + if page_ctx is None: + page_name = getattr(page_fn, "__name__", repr(page_fn)) + msg = ( + f"No compiler plugin was able to evaluate page {page.route!r} " + f"({page_name})." + ) + raise RuntimeError(msg) + if page_ctx.route in self.compiled_pages: + msg = f"Duplicate compiled page route {page_ctx.route!r}." + raise RuntimeError(msg) + + if len(all_base_state_classes) > n_states_before: + self.stateful_routes[page.route] = None + + if overlay_component is not None and self.app is not None: + if not isinstance(page_ctx.root_component, Component): + msg = ( + f"Compiled page {page_ctx.route!r} root must be a Component " + "to apply the overlay." + ) + raise TypeError(msg) + page_ctx.root_component = self.app._add_overlay_to_component( + page_ctx.root_component, + overlay_component, + ) + + page_ctx.root_component = ( + StatefulComponent.compile_from(page_ctx.root_component) + or page_ctx.root_component + ) + self.compiled_pages[page_ctx.route] = page_ctx + + if evaluate_progress is not None: + evaluate_progress() + + page_components = [ + page_ctx.root_component for page_ctx in self.compiled_pages.values() + ] + self.stateful_components_code = ( + compiler._compile_stateful_components(page_components) + if is_prod_mode() + else "" + ) + + for page, page_ctx in zip( + self.pages, + self.compiled_pages.values(), + strict=True, + ): + with page_ctx: + page_ctx.root_component = self.hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=self, + ) + self.hooks.compile_page( + page_ctx, + page=page, + compile_context=self, + **kwargs, + ) + + page_ctx.frontend_imports = page_ctx.merged_imports(collapse=True) + self.all_imports = merge_imports( + self.all_imports, page_ctx.frontend_imports + ) + self.app_wrap_components.update(page_ctx.app_wrap_components) + page_ctx.output_path, page_ctx.output_code = ( + compiler.compile_page_from_context(page_ctx) + ) + + if render_progress is not None: + render_progress() + + return self.compiled_pages + + +__all__ = [ + "BaseContext", + "CompileContext", + "CompilerHooks", + "CompilerPlugin", + "ComponentAndChildren", + "PageContext", + "PageDefinition", +] diff --git a/packages/reflex-core/src/reflex_core/utils/console.py b/packages/reflex-core/src/reflex_core/utils/console.py index de7f61c6ac8..4ab788e8d38 100644 --- a/packages/reflex-core/src/reflex_core/utils/console.py +++ b/packages/reflex-core/src/reflex_core/utils/console.py @@ -479,6 +479,18 @@ def advance(self, task: TaskID, advance: int = 1): self.progress += advance _console.print(f"Progress: {self.progress}/{self.total}") + def update(self, task: TaskID, total: int | None = None): + """Update properties of a task. + + Args: + task: The task ID. + total: New total for the task. + """ + if total is not None and task in self.tasks: + previous_total = self.tasks[task]["total"] + self.tasks[task]["total"] = total + self.total += total - previous_total + def start(self): """Start the progress bar.""" diff --git a/pyi_hashes.json b/pyi_hashes.json index 62611f50a6d..39121b1c2f2 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,123 +1,4 @@ { - "packages/reflex-components-code/src/reflex_components_code/code.pyi": "a252d3efb9c621216c3ac32327158a83", - "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "2ae0bc697886c5a735afbe232a84f022", - "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "e4f253225cf70b62900e25d0a5c16436", - "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "407342f78a72e87489c8b22e40de68b9", - "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "8a0b6dcdf622b96be65311b7803c8ce9", - "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "aa734326f57b0fee9caed75bd318762e", - "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "8edb8967aa628329c4d1b7cfa3705f3a", - "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "c02999fa5d121904a242a83d2221f069", - "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "ba9a750fa1036dd4f454e7f3235aa4aa", - "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "fbae966c13c0da651a1e35f7045799c1", - "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "9c1df9038ff6394cac77dbac6b3175c5", - "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "434ca63fb809077642112d53879380f5", - "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "4002f8ac81d1b38177c3b837cbc3b44d", - "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "3c22950d97f6017b8e6cc6a6c83cb4b3", - "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "3f8b625f5b38a9351b01201c7adb2ca0", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "b2e4b26b13f33d8900550fedd2d5f447", - "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "4164c841934cab71b1c4b132d15663f5", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "d01e9934bcfd81b5fc969d82e362ac20", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "3bf7bee5665293f7583009f651ea3cb1", - "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "7209d1607545e412ed38dbe2a129321c", - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "8241c75ca16a0960b7dea6d6e7aff52e", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "73e38c074d7e6ca2fda8eaad820f177e", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "4407cecb1825dc359bcc7b2bea011a8e", - "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "aaab42816119ac0f308841dc5482b3f1", - "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "e27fddec8de079db37d6699e136411d1", - "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "6add8b77380ea3702031b07330fc7d60", - "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "721b328e94510f8328728be1657abbb8", - "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c427fcd82fc6ccf86b4d2b5c4756426", - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "5912179a169da4dc3b152042558be2cf", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "6bf366f345e14a556dbb3c0f230e1355", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f8d2a995e488ebc5e8633977151758ce", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "20d803fcc05d4c378547ceaa0e1bcc70", - "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "1cf906cbc2751f87adbcd85e03b72d2e", - "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "b4b5bb69e6ce8d08c0df51301e132af4", - "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "4ce119b25459a01d128bdb5b79b0d128", - "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "9e58353a97dc006d37d2c7c50506fac4", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "3325f8a4af0aadb70cbfc50558e2f3b2", - "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "d77a80f688b29d2e1048007172d2b65f", - "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "caa83be6f97faa95588bfa9ae9e9331e", - "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "32880736442800061a39ce4b55267eaf", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "e55c023c9ecc907321f163955f4c4875", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "bacf19a5b6904281d7238dbd51e6fc1c", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "b997bdd994844f0e6ca923bbb2dc34a1", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "65a93d778a0fde06975dac9244f51bb3", - "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "4843dd071acb073dc30028322c3d4023", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bc25cae0eca01c8684443d5dfd7b6455", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "6dd30847af62ad7d50d5c5daf6c4a1d7", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "6c12ef3d9f82926bf17d410b774d56f5", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "810fa8c626b79035cdbb04f43b5bc5ad", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cb67e835f9be41f70ee2bae0f8c0a764", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "ba31009535c078df0bc5a26bce6dfd2b", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "2356caa9e23f9c8888cccbbb41b57985", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "f694033992ef188f2da04e865d5a7d77", - "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "5d9a06872953d3e3df99e1ff154a4e0c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "d7c20bd180f28fdb4affcba37e2aa1ff", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ba0ff3b00289cd1896e327fa2be99563", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "2b0f9f472ba6dcc743c2df17642c4a4b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "fad40b463a8ebb0d3ca3900dc8a91679", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0d22969c5407592a0bb36768e149f2b5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "6a6e9b8f6ca3428c45d62bd0e7f94693", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "d96b2048ae17a558d9eb3378ae98524e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "1c7f518e1881e98614eadff952da0844", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "be245c1c3796f695ac4b2d77c3b88a3a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "b56fa19913ed15d9e630951e70479b36", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "f33d86a3bb176e3144570198ce5f93ae", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "10af49cf574b738d616803df2c055ad0", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "3edbddceb585fd80d9e7959977ff276e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "0a06f6fa5cf8a2590c302f618451ca65", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "82508b83193afde0b3bc06911cb78f87", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "8f21ba52183221d4cf0b8beaacd8e006", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "b167c32571142878305d98c0bd656b09", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "8009f36c543c1407e2aa7ead41178ceb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "3814bb2950e2bcc454d186d50d123e9f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "31af9b53ec38736ab7457ea731642869", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "2ca6dfe4f9e00f2647f0ad4fd131e6d3", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "7a874fa512ce2d8a490aa41531f5814b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "465a6d6e9525ac909b4f193d2d788682", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "ddb2835ecbeaf90681e4030a14d74604", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "d5333b59e6ba9ad30923d2b60d0e382e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "6b2d881a8ecdf4dd169b341418a703db", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "79764047f53543d673d6e1b2c929d9b8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "f71a320b02ac8f1d6db07b9198b296ec", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "f8420b5196edb74275d2119d780d0031", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "574407d03b311ca9cdf0f98ab53a6fbe", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "9e26688af77fab944635e16e0bf7283f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "3477cc5e00146eaa2cde5d35f9459ad6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "37e0c8dc43c5a24bdba03429e3ca9052", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "c910ebd02d7a78627f884e3431426552", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ead639a106a76cc0e3fd2c8f093f9f23", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "1a03d9525a1544816392067499c3354d", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "c2fbd8547de4993e03017844e8c4b477", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "4545b70fd0802f19993419ab0163d595", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "417e490adc15e93dc2cdb854ee0361d2", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "3195e198d92ff43644a09c277303b83b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "69d12b6c918a476ac4557f42fef73c27", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "88880d197ff7347ec7d3f81d6e57de8e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "f329387a5d4a988bc195e6a487ff44db", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "855c9d0c3c2e79e7d3811cfec74d6379", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "bd2c31d4e3d61743b72327f071969e05", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "eee53b418ff0e0660c8cf9d8a0a59386", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "1aa57142797597d65d840eb1d3cc7de7", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "c774f0a1384f983e6d73bde603c341ca", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "894dcd5945123c1c8aa34cb77602fead", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "3926877c04f74fc2acf4a398bee9da06", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "305a8932078e4af48e44489e7ce74060", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "fad43053747fb84229cc35296c7028b5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "5901c7202a5b135f60cc1407878b4859", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "d567c1242672d125015920f7ae6e6999", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "76f100da40d0e18ad4f7b3387dec1d4a", - "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "a981a6031015c3a384e6255be88885f1", - "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "1f66ea4fa34e8a8fa7473d312daf84b8", - "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "8c1ea5bf4ec27ec6ff2dce462021b094", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "2610c28416f80e2254bd10dde8c29bdf", - "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "597b9eb86c57f5293c13c128fb972c27", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "303d4b1dc72c08339154907b9b095365", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "6e9371bddea95f8e2491d9b3c7e250cd", - "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1ce679c002336c7bdbdd6c8ff6f2413c", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "1b92135de4ea79cb7d94eaaec55b9ab7", - "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f09c503c4ab880c13c13d6fa67d708b8", "reflex/__init__.pyi": "7696c38fd9c04a598518b49c5185c414", "reflex/components/__init__.pyi": "55bb242d5e5428db329b88b4923c2ba5", "reflex/experimental/memo.pyi": "d16eccf33993c781e2f8bc2dd8bbd4d4" diff --git a/reflex/app.py b/reflex/app.py index 375e869d288..d4a504ed78e 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import concurrent.futures import contextlib import copy import dataclasses @@ -19,21 +18,16 @@ AsyncGenerator, AsyncIterator, Callable, + Collection, Coroutine, Mapping, Sequence, ) -from datetime import datetime -from itertools import chain -from pathlib import Path -from timeit import default_timer as timer from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, ParamSpec +from typing import TYPE_CHECKING, Any -from reflex_components_core.base.app_wrap import AppWrap from reflex_components_core.base.error_boundary import ErrorBoundary from reflex_components_core.base.fragment import Fragment -from reflex_components_core.base.strict_mode import StrictMode from reflex_components_core.core.banner import ( backend_disabled, connection_pulser, @@ -44,14 +38,9 @@ from reflex_components_radix import themes from reflex_components_sonner.toast import toast from reflex_core import constants -from reflex_core.components.component import ( - CUSTOM_COMPONENTS, - Component, - ComponentStyle, - evaluate_style_namespaces, -) +from reflex_core.components.component import Component, ComponentStyle from reflex_core.config import get_config -from reflex_core.environment import ExecutorType, environment +from reflex_core.environment import environment from reflex_core.event import ( _EVENT_FIELDS, Event, @@ -64,7 +53,6 @@ from reflex_core.utils import console from reflex_core.utils.imports import ImportVar from reflex_core.utils.types import ASGIApp, Message, Receive, Scope, Send -from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from socketio import ASGIApp as EngineIOApp from socketio import AsyncNamespace, AsyncServer from starlette.applications import Starlette @@ -79,13 +67,7 @@ from reflex.admin import AdminDash from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.compiler import compiler -from reflex.compiler import utils as compiler_utils -from reflex.compiler.compiler import ( - ExecutorSafeFunctions, - compile_theme, - readable_name_from_component, -) -from reflex.experimental.memo import EXPERIMENTAL_MEMOS +from reflex.compiler.compiler import readable_name_from_component from reflex.istate.manager import StateManager, StateModificationContext from reflex.istate.proxy import StateProxy from reflex.page import DECORATED_PAGES @@ -102,17 +84,8 @@ _split_substate_key, _substate_key, all_base_state_classes, - code_uses_state_contexts, -) -from reflex.utils import ( - codespaces, - exceptions, - format, - frontend_skeleton, - js_runtimes, - path_ops, - prerequisites, ) +from reflex.utils import codespaces, exceptions, format, js_runtimes, prerequisites from reflex.utils.exec import ( get_compile_context, is_prod_mode, @@ -279,9 +252,6 @@ def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage: ) -P = ParamSpec("P") - - @dataclasses.dataclass() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -955,7 +925,10 @@ def _setup_admin_dash(self): admin.mount_to(self._api) - def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]): + def _get_frontend_packages( + self, + imports: Mapping[str, Collection[ImportVar]], + ) -> None: """Gets the frontend packages to be installed and filters out the unnecessary ones. Args: @@ -1123,391 +1096,13 @@ def _compile( ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined. FileNotFoundError: When a plugin requires a file that does not exist. """ - from reflex_core.utils.exceptions import ReflexRuntimeError - - self._apply_decorated_pages() - - self._pages = {} - - def get_compilation_time() -> str: - return str(datetime.now().time()).split(".")[0] - - should_compile = self._should_compile() - backend_dir = prerequisites.get_backend_dir() - if not dry_run and not should_compile and backend_dir.exists(): - stateful_pages_marker = backend_dir / constants.Dirs.STATEFUL_PAGES - if stateful_pages_marker.exists(): - with stateful_pages_marker.open("r") as f: - stateful_pages = json.load(f) - for route in stateful_pages: - console.debug(f"BE Evaluating stateful page: {route}") - self._compile_page(route, save_page=False) - self._add_optional_endpoints() - return - - # Render a default 404 page if the user didn't supply one - if constants.Page404.SLUG not in self._unevaluated_pages: - self.add_page(route=constants.Page404.SLUG) - - # Fix up the style. - self.style = evaluate_style_namespaces(self.style) - - # Add the app wrappers. - app_wrappers: dict[tuple[int, str], Component] = { - # Default app wrap component renders {children} - (0, "AppWrap"): AppWrap.create() - } - - if self.theme is not None: - # If a theme component was provided, wrap the app with it - app_wrappers[20, "Theme"] = self.theme - - # Get the env mode. - config = get_config() - - if config.react_strict_mode: - app_wrappers[200, "StrictMode"] = StrictMode.create() - - if not should_compile and not dry_run: - with console.timing("Evaluate Pages (Backend)"): - for route in self._unevaluated_pages: - console.debug(f"Evaluating page: {route}") - self._compile_page(route, save_page=should_compile) - - # Save the pages which created new states at eval time. - self._write_stateful_pages_marker() - - # Add the optional endpoints (_upload) - self._add_optional_endpoints() - - return - - # Create a progress bar. - progress = ( - Progress( - *Progress.get_default_columns()[:-1], - MofNCompleteColumn(), - TimeElapsedColumn(), - ) - if use_rich - else console.PoorProgress() - ) - - # try to be somewhat accurate - but still not 100% - adhoc_steps_without_executor = 7 - fixed_pages_within_executor = 4 - plugin_count = len(config.plugins) - progress.start() - task = progress.add_task( - f"[{get_compilation_time()}] Compiling:", - total=len(self._unevaluated_pages) - + ((len(self._unevaluated_pages) + len(self._pages)) * 3) - + fixed_pages_within_executor - + adhoc_steps_without_executor - + plugin_count, - ) - - with console.timing("Evaluate Pages (Frontend)"): - performance_metrics: list[tuple[str, float]] = [] - for route in self._unevaluated_pages: - console.debug(f"Evaluating page: {route}") - start = timer() - self._compile_page(route, save_page=should_compile) - end = timer() - performance_metrics.append((route, end - start)) - progress.advance(task) - console.debug( - "Slowest pages:\n" - + "\n".join( - f"{route}: {time * 1000:.1f}ms" - for route, time in sorted( - performance_metrics, key=operator.itemgetter(1), reverse=True - )[:10] - ) - ) - # Save the pages which created new states at eval time. - self._write_stateful_pages_marker() - - # Add the optional endpoints (_upload) - self._add_optional_endpoints() - - self._validate_var_dependencies() - self._setup_overlay_component() - - if config.show_built_with_reflex is None: - if ( - get_compile_context() == constants.CompileContext.DEPLOY - and prerequisites.get_user_tier() in ["pro", "team", "enterprise"] - ): - config.show_built_with_reflex = False - else: - config.show_built_with_reflex = True - - if is_prod_mode() and config.show_built_with_reflex: - self._setup_sticky_badge() - - progress.advance(task) - - # Store the compile results. - compile_results: list[tuple[str, str]] = [] - - progress.advance(task) - - # Track imports found. - all_imports = {} - - if (toaster := self.toaster) is not None: - from reflex_core.components.component import memo - - @memo - def memoized_toast_provider(): - return toaster - - toast_provider = Fragment.create(memoized_toast_provider()) - - app_wrappers[44, "ToasterProvider"] = toast_provider - - # Add the app wraps to the app. - for key, app_wrap in chain( - self.app_wraps.items(), self.extra_app_wraps.items() - ): - # If the app wrap is a callable, generate the component - component = app_wrap(self._state is not None) - if component is not None: - app_wrappers[key] = component - - # Compile custom components. - ( - memo_components_output, - memo_components_result, - memo_components_imports, - ) = compiler.compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), - tuple(EXPERIMENTAL_MEMOS.values()), - ) - compile_results.append((memo_components_output, memo_components_result)) - all_imports.update(memo_components_imports) - progress.advance(task) - - with console.timing("Collect all imports and app wraps"): - # This has to happen before compiling stateful components as that - # prevents recursive functions from reaching all components. - for component in self._pages.values(): - # Add component._get_all_imports() to all_imports. - all_imports.update(component._get_all_imports()) - - # Add the app wrappers from this component. - app_wrappers.update(component._get_all_app_wrap_components()) - - progress.advance(task) - - # Perform auto-memoization of stateful components. - with console.timing("Auto-memoize StatefulComponents"): - ( - stateful_components_path, - stateful_components_code, - page_components, - ) = compiler.compile_stateful_components( - self._pages.values(), - progress_function=lambda task=task: progress.advance(task), - ) - progress.advance(task) - - # Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State. - if code_uses_state_contexts(stateful_components_code) and self._state is None: - msg = ( - "To access rx.State in frontend components, at least one " - "subclass of rx.State must be defined in the app." - ) - raise ReflexRuntimeError(msg) - compile_results.append((stateful_components_path, stateful_components_code)) - - progress.advance(task) - - # Compile the root document before fork. - compile_results.append( - compiler.compile_document_root( - self.head_components, - html_lang=self.html_lang, - html_custom_attrs=( - {"suppressHydrationWarning": True, **self.html_custom_attrs} - if self.html_custom_attrs - else {"suppressHydrationWarning": True} - ), - ) - ) - - progress.advance(task) - - # Copy the assets. - assets_src = Path.cwd() / constants.Dirs.APP_ASSETS - if assets_src.is_dir() and not dry_run: - with console.timing("Copy assets"): - path_ops.update_directory_tree( - src=assets_src, - dest=( - Path.cwd() / prerequisites.get_web_dir() / constants.Dirs.PUBLIC - ), - ) - - executor = ExecutorType.get_executor_from_environment() - - for route, component in zip(self._pages, page_components, strict=True): - ExecutorSafeFunctions.COMPONENTS[route] = component - - modify_files_tasks: list[tuple[str, str, Callable[[str], str]]] = [] - - with console.timing("Compile to Javascript"), executor as executor: - result_futures: list[ - concurrent.futures.Future[ - list[tuple[str, str]] | tuple[str, str] | None - ] - ] = [] - - def _submit_work( - fn: Callable[P, list[tuple[str, str]] | tuple[str, str] | None], - *args: P.args, - **kwargs: P.kwargs, - ): - f = executor.submit(fn, *args, **kwargs) - f.add_done_callback(lambda _: progress.advance(task)) - result_futures.append(f) - - # Compile the pre-compiled pages. - for route in self._pages: - _submit_work( - ExecutorSafeFunctions.compile_page, - route, - ) - - # Compile the root stylesheet with base styles. - _submit_work( - compiler.compile_root_stylesheet, self.stylesheets, self.reset_style - ) - - # Compile the theme. - _submit_work(compile_theme, self.style) - - def _submit_work_without_advancing( - fn: Callable[P, list[tuple[str, str]] | tuple[str, str] | None], - *args: P.args, - **kwargs: P.kwargs, - ): - f = executor.submit(fn, *args, **kwargs) - result_futures.append(f) - - for plugin in config.plugins: - plugin.pre_compile( - add_save_task=_submit_work_without_advancing, - add_modify_task=( - lambda *args, plugin=plugin: modify_files_tasks.append(( - plugin.__class__.__module__ + plugin.__class__.__name__, - *args, - )) - ), - unevaluated_pages=list(self._unevaluated_pages.values()), - ) - - # Wait for all compilation tasks to complete. - for future in concurrent.futures.as_completed(result_futures): - if (result := future.result()) is not None: - if isinstance(result, list): - compile_results.extend(result) - else: - compile_results.append(result) - - progress.advance(task, advance=len(config.plugins)) - - app_root = self._app_root(app_wrappers=app_wrappers) - - # Get imports from AppWrap components. - all_imports.update(app_root._get_all_imports()) - - progress.advance(task) - - # Compile the contexts. - compile_results.append( - compiler.compile_contexts(self._state, self.theme), - ) - if self.theme is not None: - # Fix #2992 by removing the top-level appearance prop - self.theme.appearance = None # pyright: ignore[reportAttributeAccessIssue] - progress.advance(task) - - # Compile the app root. - compile_results.append( - compiler.compile_app(app_root), - ) - progress.advance(task) - - progress.stop() - - if dry_run: - return - - # Install frontend packages. - with console.timing("Install Frontend Packages"): - self._get_frontend_packages(all_imports) - - # Setup the react-router.config.js - frontend_skeleton.update_react_router_config( + compiler.compile_app( + self, prerender_routes=prerender_routes, + dry_run=dry_run, + use_rich=use_rich, ) - if is_prod_mode(): - # Empty the .web pages directory. - compiler.purge_web_pages_dir() - else: - # In dev mode, delete removed pages and update existing pages. - keep_files = [Path(output_path) for output_path, _ in compile_results] - for p in Path( - prerequisites.get_web_dir() - / constants.Dirs.PAGES - / constants.Dirs.ROUTES - ).rglob("*"): - if p.is_file() and p not in keep_files: - # Remove pages that are no longer in the app. - p.unlink() - - output_mapping: dict[Path, str] = {} - for output_path, code in compile_results: - path = compiler_utils.resolve_path_of_web_dir(output_path) - if path in output_mapping: - console.warn( - f"Path {path} has two different outputs. The first one will be used." - ) - else: - output_mapping[path] = code - - for plugin in config.plugins: - for static_file_path, content in plugin.get_static_assets(): - path = compiler_utils.resolve_path_of_web_dir(static_file_path) - if path in output_mapping: - console.warn( - f"Plugin {plugin.__class__.__name__} is trying to write to {path} but it already exists. The plugin file will be ignored." - ) - else: - output_mapping[path] = ( - content.decode("utf-8") - if isinstance(content, bytes) - else content - ) - - for plugin_name, file_path, modify_fn in modify_files_tasks: - path = compiler_utils.resolve_path_of_web_dir(file_path) - file_content = output_mapping.get(path) - if file_content is None: - if path.exists(): - file_content = path.read_text() - else: - msg = f"Plugin {plugin_name} is trying to modify {path} but it does not exist." - raise FileNotFoundError(msg) - output_mapping[path] = modify_fn(file_content) - - with console.timing("Write to Disk"): - for output_path, code in output_mapping.items(): - compiler_utils.write_file(output_path, code) - def _write_stateful_pages_marker(self): """Write list of routes that create dynamic states for the backend to use later.""" if self._state is not None: diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index d17a8baf012..eb4571aff48 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -2,43 +2,60 @@ from __future__ import annotations +import json import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule from pathlib import Path from typing import TYPE_CHECKING, Any +from reflex_components_core.base.app_wrap import AppWrap from reflex_components_core.base.fragment import Fragment from reflex_core import constants from reflex_core.components.component import ( + CUSTOM_COMPONENTS, BaseComponent, Component, ComponentStyle, CustomComponent, StatefulComponent, + evaluate_style_namespaces, ) from reflex_core.config import get_config from reflex_core.constants.compiler import PageNames, ResetStylesheet from reflex_core.constants.state import FIELD_MARKER from reflex_core.environment import environment +from reflex_core.plugins import CompileContext, CompilerHooks, PageContext from reflex_core.style import SYSTEM_COLOR_MODE from reflex_core.utils.exceptions import ReflexError from reflex_core.utils.format import to_title_case from reflex_core.utils.imports import ImportVar, ParsedImportDict from reflex_core.vars.base import LiteralVar, Var +from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from reflex.compiler import templates, utils +from reflex.compiler.plugins import default_page_plugins from reflex.experimental.memo import ( + EXPERIMENTAL_MEMOS, ExperimentalMemoComponentDefinition, ExperimentalMemoDefinition, ExperimentalMemoFunctionDefinition, ) -from reflex.state import BaseState -from reflex.utils import console, path_ops -from reflex.utils.exec import is_prod_mode +from reflex.state import BaseState, code_uses_state_contexts +from reflex.utils import console, frontend_skeleton, path_ops, prerequisites +from reflex.utils.exec import get_compile_context, is_prod_mode from reflex.utils.prerequisites import get_web_dir +def _set_progress_total( + progress: Progress | console.PoorProgress, + task: Any, + total: int, +) -> None: + """Update a task total for either rich or fallback progress bars.""" + progress.update(task, total=total) + + def _apply_common_imports( imports: dict[str, list[ImportVar]], ): @@ -521,7 +538,7 @@ def compile_document_root( return output_path, code -def compile_app(app_root: Component) -> tuple[str, str]: +def compile_app_root(app_root: Component) -> tuple[str, str]: """Compile the app root. Args: @@ -596,6 +613,34 @@ def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: return output_path, code +def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: + """Compile a single page from a collected page context. + + Args: + page_ctx: The collected page context to render. + + Returns: + The path and code of the compiled page. + """ + output_path = utils.get_page_path(page_ctx.route) + imports = { + lib: list(fields) + for lib, fields in ( + page_ctx.frontend_imports or page_ctx.merged_imports(collapse=True) + ).items() + } + _apply_common_imports(imports) + + code = templates.page_template( + imports=utils.compile_imports(imports), + dynamic_imports=sorted(page_ctx.dynamic_imports), + custom_codes=page_ctx.custom_code_dict(), + hooks=page_ctx.hooks, + render=page_ctx.root_component.render(), + ) + return output_path, code + + def compile_memo_components( components: Iterable[CustomComponent], experimental_memos: Iterable[ExperimentalMemoDefinition] = (), @@ -661,7 +706,7 @@ def purge_web_pages_dir(): if TYPE_CHECKING: - from reflex.app import ComponentCallable, UnevaluatedPage + from reflex.app import App, ComponentCallable, UnevaluatedPage def _into_component_once( @@ -871,82 +916,340 @@ def compile_unevaluated_page( return component -class ExecutorSafeFunctions: - """Helper class to allow parallelisation of parts of the compilation process. - - This class (and its class attributes) are available at global scope. +def _compile_page_from_app( + app: App, + route: str, + *, + save_page: bool = True, +) -> None: + """Evaluate a page from an app and optionally save it. - In a multiprocessing context (like when using a ProcessPoolExecutor), the content of this - global class is logically replicated to any FORKED process. + Args: + app: The app being compiled. + route: The route to evaluate. + save_page: Whether to store the evaluated page on the app. + """ + app._compile_page(route, save_page=save_page) - How it works: - * Before the child process is forked, ensure that we stash any input data required by any future - function call in the child process. - * After the child process is forked, the child process will have a copy of the global class, which - includes the previously stashed input data. - * Any task submitted to the child process simply needs a way to communicate which input data the - requested function call requires. - Why do we need this? Passing input data directly to child process often not possible because the input data is not picklable. - The mechanic described here removes the need to pickle the input data at all. +def _resolve_app_wrap_components( + app: App, + page_app_wrap_components: dict[tuple[int, str], Component], +) -> dict[tuple[int, str], Component]: + """Build the full app-wrap registry for compilation. - Limitations: - * This can never support returning unpicklable OUTPUT data. - * Any object mutations done by the child process will not propagate back to the parent process (fork goes one way!). + Args: + app: The app being compiled. + page_app_wrap_components: App-wrap components collected from pages. + Returns: + The merged app-wrap component registry. """ + config = get_config() + + app_wrappers: dict[tuple[int, str], Component] = { + (0, "AppWrap"): AppWrap.create(), + } + app_wrappers.update(page_app_wrap_components) + + if app.theme is not None: + app_wrappers[20, "Theme"] = app.theme + + if config.react_strict_mode: + from reflex_components_core.base.strict_mode import StrictMode + + app_wrappers[200, "StrictMode"] = StrictMode.create() + + if (toaster := app.toaster) is not None: + from reflex_core.components.component import memo + + @memo + def memoized_toast_provider(): + return toaster + + app_wrappers[44, "ToasterProvider"] = Fragment.create(memoized_toast_provider()) + + for wrap_mapping in (app.app_wraps, app.extra_app_wraps): + for key, app_wrap in wrap_mapping.items(): + component = app_wrap(app._state is not None) + if component is not None: + app_wrappers[key] = component + + return app_wrappers + + +def compile_app( + app: App, + *, + prerender_routes: bool = False, + dry_run: bool = False, + use_rich: bool = True, +) -> None: + """Compile an app using the compiler plugin pipeline.""" + from reflex_core.utils.exceptions import ReflexRuntimeError + + app._apply_decorated_pages() + app._pages = {} + + should_compile = app._should_compile() + backend_dir = prerequisites.get_backend_dir() + if not dry_run and not should_compile and backend_dir.exists(): + stateful_pages_marker = backend_dir / constants.Dirs.STATEFUL_PAGES + if stateful_pages_marker.exists(): + with stateful_pages_marker.open("r") as file: + stateful_pages = json.load(file) + for route in stateful_pages: + console.debug(f"BE Evaluating stateful page: {route}") + _compile_page_from_app(app, route, save_page=False) + app._add_optional_endpoints() + return + + if constants.Page404.SLUG not in app._unevaluated_pages: + app.add_page(route=constants.Page404.SLUG) + + app.style = evaluate_style_namespaces(app.style) + config = get_config() + + if not should_compile and not dry_run: + with console.timing("Evaluate Pages (Backend)"): + for route in app._unevaluated_pages: + console.debug(f"Evaluating page: {route}") + _compile_page_from_app(app, route, save_page=False) + + app._write_stateful_pages_marker() + app._add_optional_endpoints() + return - COMPONENTS: dict[str, BaseComponent] = {} - UNCOMPILED_PAGES: dict[str, UnevaluatedPage] = {} - - @classmethod - def compile_page(cls, route: str) -> tuple[str, str]: - """Compile a page. - - Args: - route: The route of the page to compile. - - Returns: - The path and code of the compiled page. - """ - return compile_page(route, cls.COMPONENTS[route]) - - @classmethod - def compile_unevaluated_page( - cls, - route: str, - style: ComponentStyle, - theme: Component | None, - ) -> tuple[str, Component, tuple[str, str]]: - """Compile an unevaluated page. - - Args: - route: The route of the page to compile. - style: The style of the page. - theme: The theme of the page. - - Returns: - The route, compiled component, and compiled page. - """ - component = compile_unevaluated_page( - route, cls.UNCOMPILED_PAGES[route], style, theme + progress = ( + Progress( + *Progress.get_default_columns()[:-1], + MofNCompleteColumn(), + TimeElapsedColumn(), ) - return route, component, compile_page(route, component) + if use_rich + else console.PoorProgress() + ) + fixed_steps = 7 + base_total = (len(app._unevaluated_pages) * 2) + fixed_steps + len(config.plugins) + progress.start() + task = progress.add_task("Compiling:", total=base_total) + + compile_ctx = CompileContext( + app=app, + pages=list(app._unevaluated_pages.values()), + hooks=CompilerHooks( + plugins=default_page_plugins(style=app.style, theme=app.theme) + ), + ) + + with console.timing("Compile pages"), compile_ctx: + compile_ctx.compile( + apply_overlay=True, + evaluate_progress=lambda: progress.advance(task), + render_progress=lambda: progress.advance(task), + ) + + for route, page_ctx in compile_ctx.compiled_pages.items(): + app._check_routes_conflict(route) + if not isinstance(page_ctx.root_component, Component): + msg = ( + f"Compiled page {route!r} root must be a Component before it can " + "be registered on the app." + ) + raise TypeError(msg) + app._pages[route] = page_ctx.root_component + + app._stateful_pages.update(compile_ctx.stateful_routes) + app._write_stateful_pages_marker() + app._add_optional_endpoints() + app._validate_var_dependencies() + + if config.show_built_with_reflex is None: + if ( + get_compile_context() == constants.CompileContext.DEPLOY + and prerequisites.get_user_tier() in ["pro", "team", "enterprise"] + ): + config.show_built_with_reflex = False + else: + config.show_built_with_reflex = True - @classmethod - def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]: - """Compile the theme. + if is_prod_mode() and config.show_built_with_reflex: + app._setup_sticky_badge() - Args: - style: The style to compile. + progress.advance(task) - Returns: - The path and code of the compiled theme. + compile_results = [ + (page_ctx.output_path, page_ctx.output_code) + for page_ctx in compile_ctx.compiled_pages.values() + if page_ctx.output_path is not None and page_ctx.output_code is not None + ] + all_imports = compile_ctx.all_imports + + ( + memo_components_output, + memo_components_result, + memo_components_imports, + ) = compile_memo_components( + dict.fromkeys(CUSTOM_COMPONENTS.values()), + tuple(EXPERIMENTAL_MEMOS.values()), + ) + compile_results.append((memo_components_output, memo_components_result)) + all_imports = utils.merge_imports(all_imports, memo_components_imports) + progress.advance(task) + + if ( + code_uses_state_contexts(compile_ctx.stateful_components_code) + and app._state is None + ): + msg = ( + "To access rx.State in frontend components, at least one " + "subclass of rx.State must be defined in the app." + ) + raise ReflexRuntimeError(msg) + if compile_ctx.stateful_components_path is not None: + compile_results.append(( + compile_ctx.stateful_components_path, + compile_ctx.stateful_components_code, + )) + progress.advance(task) + + app_wrappers = _resolve_app_wrap_components(app, compile_ctx.app_wrap_components) + app_root = app._app_root(app_wrappers) + all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) + + compile_results.append( + compile_document_root( + app.head_components, + html_lang=app.html_lang, + html_custom_attrs=( + {"suppressHydrationWarning": True, **app.html_custom_attrs} + if app.html_custom_attrs + else {"suppressHydrationWarning": True} + ), + ) + ) + progress.advance(task) + + assets_src = Path.cwd() / constants.Dirs.APP_ASSETS + if assets_src.is_dir() and not dry_run: + with console.timing("Copy assets"): + path_ops.update_directory_tree( + src=assets_src, + dest=Path.cwd() / prerequisites.get_web_dir() / constants.Dirs.PUBLIC, + ) + + save_tasks: list[ + tuple[ + Callable[..., list[tuple[str, str]] | tuple[str, str] | None], + tuple[Any, ...], + dict[str, Any], + ] + ] = [] + modify_files_tasks: list[tuple[str, str, Callable[[str], str]]] = [] + + def add_save_task( + task_fn: Callable[..., list[tuple[str, str]] | tuple[str, str] | None], + /, + *args: Any, + **kwargs: Any, + ) -> None: + save_tasks.append((task_fn, args, kwargs)) + + for plugin in config.plugins: + plugin.pre_compile( + add_save_task=add_save_task, + add_modify_task=lambda *args, plugin=plugin: modify_files_tasks.append(( + plugin.__class__.__module__ + plugin.__class__.__name__, + *args, + )), + unevaluated_pages=list(app._unevaluated_pages.values()), + ) + + if save_tasks: + _set_progress_total(progress, task, base_total + len(save_tasks)) + + progress.advance(task, advance=len(config.plugins)) + + compile_results.append(compile_root_stylesheet(app.stylesheets, app.reset_style)) + progress.advance(task) + + compile_results.append(compile_theme(app.style)) + progress.advance(task) + + for task_fn, args, kwargs in save_tasks: + result = task_fn(*args, **kwargs) + if result is None: + progress.advance(task) + continue + if isinstance(result, list): + compile_results.extend(result) + else: + compile_results.append(result) + progress.advance(task) + + compile_results.append(compile_contexts(app._state, app.theme)) + if app.theme is not None: + app.theme.appearance = None # pyright: ignore[reportAttributeAccessIssue] + progress.advance(task) + + compile_results.append(compile_app_root(app_root)) + progress.advance(task) + + progress.stop() + + if dry_run: + return + + with console.timing("Install Frontend Packages"): + app._get_frontend_packages(all_imports) + + frontend_skeleton.update_react_router_config( + prerender_routes=prerender_routes, + ) + + if is_prod_mode(): + purge_web_pages_dir() + else: + keep_files = [Path(output_path) for output_path, _ in compile_results] + for page_file in Path( + prerequisites.get_web_dir() / constants.Dirs.PAGES / constants.Dirs.ROUTES + ).rglob("*"): + if page_file.is_file() and page_file not in keep_files: + page_file.unlink() + + output_mapping: dict[Path, str] = {} + for output_path, code in compile_results: + path = utils.resolve_path_of_web_dir(output_path) + if path in output_mapping: + console.warn( + f"Path {path} has two different outputs. The first one will be used." + ) + else: + output_mapping[path] = code + + for plugin in config.plugins: + for static_file_path, content in plugin.get_static_assets(): + path = utils.resolve_path_of_web_dir(static_file_path) + if path in output_mapping: + console.warn( + f"Plugin {plugin.__class__.__name__} is trying to write to {path} but it already exists. The plugin file will be ignored." + ) + else: + output_mapping[path] = ( + content.decode("utf-8") if isinstance(content, bytes) else content + ) + + for plugin_name, file_path, modify_fn in modify_files_tasks: + path = utils.resolve_path_of_web_dir(file_path) + file_content = output_mapping.get(path) + if file_content is None: + if path.exists(): + file_content = path.read_text() + else: + msg = f"Plugin {plugin_name} is trying to modify {path} but it does not exist." + raise FileNotFoundError(msg) + output_mapping[path] = modify_fn(file_content) - Raises: - ValueError: If the style is not set. - """ - if style is None: - msg = "STYLE should be set" - raise ValueError(msg) - return compile_theme(style) + with console.timing("Write to Disk"): + for output_path, code in output_mapping.items(): + utils.write_file(output_path, code) diff --git a/reflex/compiler/plugins/__init__.py b/reflex/compiler/plugins/__init__.py new file mode 100644 index 00000000000..24e03d9ee35 --- /dev/null +++ b/reflex/compiler/plugins/__init__.py @@ -0,0 +1,32 @@ +"""Built-in compiler plugins for single-pass page compilation.""" + +from reflex_core.plugins import ( + BaseContext, + CompileContext, + CompilerHooks, + CompilerPlugin, + ComponentAndChildren, + PageContext, + PageDefinition, +) + +from .builtin import ( + ApplyStylePlugin, + DefaultCollectorPlugin, + DefaultPagePlugin, + default_page_plugins, +) + +__all__ = [ + "ApplyStylePlugin", + "BaseContext", + "CompileContext", + "CompilerHooks", + "CompilerPlugin", + "ComponentAndChildren", + "DefaultCollectorPlugin", + "DefaultPagePlugin", + "PageContext", + "PageDefinition", + "default_page_plugins", +] diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py new file mode 100644 index 00000000000..de72755fe49 --- /dev/null +++ b/reflex/compiler/plugins/builtin.py @@ -0,0 +1,537 @@ +"""Built-in compiler plugins and the default plugin pipeline.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable +from typing import Any + +from reflex_components_core.base.fragment import Fragment +from reflex_core.components.component import ( + BaseComponent, + Component, + ComponentStyle, + StatefulComponent, +) +from reflex_core.config import get_config +from reflex_core.plugins import ( + CompileContext, + CompilerPlugin, + PageContext, + PageDefinition, + Plugin, +) +from reflex_core.utils.format import make_default_page_title +from reflex_core.utils.imports import collapse_imports, merge_imports +from reflex_core.vars import VarData + +from reflex.compiler import utils + + +@dataclasses.dataclass(frozen=True, slots=True) +class DefaultPagePlugin(Plugin): + """Evaluate an unevaluated page into a mutable page context.""" + + def eval_page( + self, + page_fn: Any, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> PageContext: + """Evaluate the page function and attach legacy page metadata. + + Returns: + The evaluated page context. + """ + from reflex.compiler import compiler + + del kwargs + + try: + component = compiler.into_component(page_fn) + component = Fragment.create(component) + + meta_args = { + "title": getattr(page, "title", None) + or make_default_page_title(get_config().app_name, page.route), + "image": getattr(page, "image", ""), + "meta": getattr(page, "meta", ()), + } + if (description := getattr(page, "description", None)) is not None: + meta_args["description"] = description + + utils.add_meta(component, **meta_args) + except Exception as err: + if hasattr(err, "add_note"): + err.add_note(f"Happened while evaluating page {page.route!r}") + raise + + return PageContext( + name=getattr(page_fn, "__name__", page.route), + route=page.route, + root_component=component, + ) + + +@dataclasses.dataclass(frozen=True, slots=True) +class ApplyStylePlugin(Plugin): + """Apply app-level styles in the descending phase of the walk.""" + + _compiler_can_replace_enter_component = False + style: ComponentStyle | None = None + theme: Component | None = None + + @staticmethod + def _apply_style(comp: Component, style: ComponentStyle) -> None: + """Apply app-level styles to a single component. + + Args: + comp: The component to style. + style: The app-level component style map. + """ + if type(comp)._add_style != Component._add_style: + msg = "Do not override _add_style directly. Use add_style instead." + raise UserWarning(msg) + + new_style = comp._add_style() + style_vars = [new_style._var_data] + + component_style = comp._get_component_style(style) + if component_style: + new_style.update(component_style) + style_vars.append(component_style._var_data) + + new_style.update(comp.style) + style_vars.append(comp.style._var_data) + new_style._var_data = VarData.merge(*style_vars) + comp.style = new_style + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + """Apply the non-recursive portion of ``_add_style_recursive``.""" + del page_context, compile_context, stateful_component + + if self.style is not None and isinstance(comp, Component) and not in_prop_tree: + self._apply_style(comp, self.style) + + def _compiler_bind_enter_component( + self, + page_context: PageContext, + compile_context: CompileContext, + ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + """Bind a positional fast-path enter hook for style application. + + Returns: + A compiled enter hook that only takes hot-loop positional state. + """ + del page_context, compile_context + + style = self.style + if style is None: + + def enter_component( + comp: BaseComponent, + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> None: + del comp, in_prop_tree, stateful_component + + return enter_component + + apply_style = self._apply_style + + def enter_component( + comp: BaseComponent, + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> None: + del stateful_component + + if not isinstance(comp, Component) or in_prop_tree: + return + + apply_style(comp, style) + + return enter_component + + +@dataclasses.dataclass(frozen=True, slots=True) +class DefaultCollectorPlugin(Plugin): + """Collect page artifacts in one fused enter/leave hook pair.""" + + _compiler_can_replace_enter_component = False + _compiler_can_replace_leave_component = False + _compiler_stateful_only_leave_component = True + stateful_custom_code_export: bool = False + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + """Collect imports and page artifacts for the active component node.""" + del compile_context + + if isinstance(comp, StatefulComponent): + if comp.rendered_as_shared: + self._extend_imports( + page_context.frontend_imports, + comp._get_all_imports(), + ) + return + + if not isinstance(comp, Component): + return + + if not in_prop_tree: + imports = comp._get_imports() + if imports: + self._extend_imports(page_context.frontend_imports, imports) + self._collect_component_custom_code( + page_context.module_code, + comp, + stateful_custom_code_export=self.stateful_custom_code_export, + ) + + if stateful_component is None: + self._collect_component_hooks(page_context.hooks, comp) + if ( + type(comp)._get_app_wrap_components + is not Component._get_app_wrap_components + ): + self._collect_app_wrap_components( + page_context.app_wrap_components, + comp, + ) + + if (dynamic_import := comp._get_dynamic_imports()) is not None: + page_context.dynamic_imports.add(dynamic_import) + + if (ref := comp.get_ref()) is not None: + page_context.refs[ref] = None + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + """Collect post-traversal artifacts for stateful components.""" + del children, compile_context, in_prop_tree, stateful_component + + if isinstance(comp, StatefulComponent) and not comp.rendered_as_shared: + page_context.module_code[ + comp._render_stateful_code(export=self.stateful_custom_code_export) + ] = None + + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + """Collapse collected imports into a single legacy-shaped entry.""" + del kwargs + if page_ctx.frontend_imports: + collapsed_imports = collapse_imports( + merge_imports(page_ctx.frontend_imports, *page_ctx.imports) + if page_ctx.imports + else page_ctx.frontend_imports + ) + page_ctx.frontend_imports = collapsed_imports + page_ctx.imports = [collapsed_imports] + return + + page_ctx.imports = ( + [collapse_imports(merge_imports(*page_ctx.imports))] + if page_ctx.imports + else [] + ) + + def _compiler_bind_enter_component( + self, + page_context: PageContext, + compile_context: CompileContext, + ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + """Bind a positional fast-path enter hook for artifact collection. + + Returns: + A compiled enter hook that only takes hot-loop positional state. + """ + del compile_context + + frontend_imports = page_context.frontend_imports + module_code = page_context.module_code + hooks = page_context.hooks + dynamic_imports = page_context.dynamic_imports + refs = page_context.refs + app_wrap_components = page_context.app_wrap_components + stateful_custom_code_export = self.stateful_custom_code_export + extend_imports = self._extend_imports + collect_component_hooks = self._collect_component_hooks + collect_component_custom_code = self._collect_component_custom_code + collect_app_wrap_components = self._collect_app_wrap_components + base_get_app_wrap_components = Component._get_app_wrap_components + + def enter_component( + comp: BaseComponent, + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> None: + if isinstance(comp, StatefulComponent): + if comp.rendered_as_shared: + extend_imports(frontend_imports, comp._get_all_imports()) + return + + if not isinstance(comp, Component): + return + + if not in_prop_tree: + imports_for_component = comp._get_imports() + if imports_for_component: + extend_imports(frontend_imports, imports_for_component) + collect_component_custom_code( + module_code, + comp, + stateful_custom_code_export=stateful_custom_code_export, + ) + + if stateful_component is None: + collect_component_hooks(hooks, comp) + if ( + type(comp)._get_app_wrap_components + is not base_get_app_wrap_components + ): + collect_app_wrap_components(app_wrap_components, comp) + + dynamic_import = comp._get_dynamic_imports() + if dynamic_import is not None: + dynamic_imports.add(dynamic_import) + + ref = comp.get_ref() + if ref is not None: + refs[ref] = None + + return enter_component + + def _compiler_bind_leave_component( + self, + page_context: PageContext, + compile_context: CompileContext, + ) -> Callable[ + [BaseComponent, tuple[BaseComponent, ...], bool, StatefulComponent | None], + None, + ]: + """Bind a positional fast-path leave hook for stateful code emission. + + Returns: + A compiled leave hook that only takes hot-loop positional state. + """ + del compile_context + + module_code = page_context.module_code + stateful_custom_code_export = self.stateful_custom_code_export + + def leave_component( + comp: BaseComponent, + children: tuple[BaseComponent, ...], + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> None: + del children, in_prop_tree, stateful_component + + if isinstance(comp, StatefulComponent) and not comp.rendered_as_shared: + module_code[ + comp._render_stateful_code(export=stateful_custom_code_export) + ] = None + + return leave_component + + @staticmethod + def _collect_component_hooks( + page_hooks: dict[str, VarData | None], + component: Component, + ) -> None: + """Collect hooks for one structural-tree component in legacy order.""" + page_hooks.update(component._get_hooks_internal()) + if (user_hooks := component._get_hooks()) is not None: + page_hooks[user_hooks] = None + page_hooks.update(component._get_added_hooks()) + + @staticmethod + def _extend_imports( + target: dict[str, list[Any]], + source: dict[str, list[Any]], + ) -> None: + """Extend a parsed import mapping in place.""" + for lib, fields in source.items(): + target.setdefault(lib, []).extend(fields) + + @staticmethod + def _collect_component_custom_code( + module_code: dict[str, None], + component: Component, + *, + stateful_custom_code_export: bool, + ) -> None: + """Collect custom code for one structural-tree component in legacy order.""" + if (custom_code := component._get_custom_code()) is not None: + module_code[custom_code] = None + + for prop_component in component._get_components_in_props(): + DefaultCollectorPlugin._collect_prop_custom_code_into( + prop_component, + module_code, + stateful_custom_code_export=stateful_custom_code_export, + ) + + for clz in component._iter_parent_classes_with_method("add_custom_code"): + for item in clz.add_custom_code(component): + module_code[item] = None + + @staticmethod + def _collect_prop_custom_code_into( + component: BaseComponent, + module_code: dict[str, None], + *, + stateful_custom_code_export: bool, + ) -> None: + """Recursively collect prop-tree custom code directly into ``module_code``.""" + if isinstance(component, StatefulComponent): + if component.rendered_as_shared: + return + + DefaultCollectorPlugin._collect_prop_custom_code_into( + component.component, + module_code, + stateful_custom_code_export=stateful_custom_code_export, + ) + module_code[ + component._render_stateful_code(export=stateful_custom_code_export) + ] = None + return + + if not isinstance(component, Component): + module_code.update(component._get_all_custom_code()) + return + + if (custom_code := component._get_custom_code()) is not None: + module_code[custom_code] = None + + for prop_component in component._get_components_in_props(): + DefaultCollectorPlugin._collect_prop_custom_code_into( + prop_component, + module_code, + stateful_custom_code_export=stateful_custom_code_export, + ) + + for clz in component._iter_parent_classes_with_method("add_custom_code"): + for item in clz.add_custom_code(component): + module_code[item] = None + + for child in component.children: + DefaultCollectorPlugin._collect_prop_custom_code_into( + child, + module_code, + stateful_custom_code_export=stateful_custom_code_export, + ) + + def _collect_app_wrap_components( + self, + page_app_wrap_components: dict[tuple[int, str], Component], + component: Component, + ) -> None: + """Collect app-wrap components for a structural-tree component.""" + direct_wrappers = component._get_app_wrap_components() + if not direct_wrappers: + return + + ignore_ids = {id(wrapper) for wrapper in page_app_wrap_components.values()} + page_app_wrap_components.update(direct_wrappers) + for wrapper in direct_wrappers.values(): + wrapper_id = id(wrapper) + if wrapper_id in ignore_ids: + continue + ignore_ids.add(wrapper_id) + self._collect_wrapper_subtree_into( + wrapper, + ignore_ids, + page_app_wrap_components, + ) + + @staticmethod + def _collect_wrapper_subtree_into( + component: Component, + ignore_ids: set[int], + components: dict[tuple[int, str], Component], + ) -> None: + """Collect nested app-wrap components into ``components``.""" + direct_wrappers = component._get_app_wrap_components() + for key, wrapper in direct_wrappers.items(): + wrapper_id = id(wrapper) + if wrapper_id in ignore_ids: + continue + ignore_ids.add(wrapper_id) + components[key] = wrapper + DefaultCollectorPlugin._collect_wrapper_subtree_into( + wrapper, + ignore_ids, + components, + ) + + for child in component.children: + if not isinstance(child, Component): + continue + child_id = id(child) + if child_id in ignore_ids: + continue + ignore_ids.add(child_id) + DefaultCollectorPlugin._collect_wrapper_subtree_into( + child, + ignore_ids, + components, + ) + + +def default_page_plugins( + *, + style: ComponentStyle | None = None, + theme: Component | None = None, + stateful_custom_code_export: bool = False, +) -> tuple[CompilerPlugin, ...]: + """Return the default compiler plugin ordering for page compilation.""" + plugins: list[CompilerPlugin] = [DefaultPagePlugin()] + if style is not None: + plugins.append(ApplyStylePlugin(style=style, theme=theme)) + plugins.append( + DefaultCollectorPlugin(stateful_custom_code_export=stateful_custom_code_export) + ) + return tuple(plugins) + + +__all__ = [ + "ApplyStylePlugin", + "DefaultCollectorPlugin", + "DefaultPagePlugin", + "default_page_plugins", +] diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py index b796e670257..fca502710fd 100644 --- a/reflex/plugins/__init__.py +++ b/reflex/plugins/__init__.py @@ -2,7 +2,14 @@ from reflex_core.plugins import * from reflex_core.plugins import ( + BaseContext, CommonContext, + CompileContext, + CompilerHooks, + CompilerPlugin, + ComponentAndChildren, + PageContext, + PageDefinition, Plugin, PreCompileContext, SitemapPlugin, @@ -12,7 +19,14 @@ ) __all__ = [ + "BaseContext", "CommonContext", + "CompileContext", + "CompilerHooks", + "CompilerPlugin", + "ComponentAndChildren", + "PageContext", + "PageDefinition", "Plugin", "PreCompileContext", "SitemapPlugin", diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py new file mode 100644 index 00000000000..6cf9ed252a3 --- /dev/null +++ b/tests/units/compiler/test_plugins.py @@ -0,0 +1,822 @@ +# ruff: noqa: D101, D102 + +import dataclasses +from collections.abc import Callable +from typing import Any + +import pytest +from reflex_components_core.base.fragment import Fragment +from reflex_core.components.component import ( + BaseComponent, + Component, + ComponentStyle, + StatefulComponent, + field, +) +from reflex_core.plugins import ( + BaseContext, + CompileContext, + CompilerHooks, + CompilerPlugin, + ComponentAndChildren, + PageContext, + PageDefinition, + Plugin, +) +from reflex_core.utils import format as format_utils +from reflex_core.utils.imports import ImportVar, collapse_imports, merge_imports + +from reflex.app import UnevaluatedPage +from reflex.compiler import compiler +from reflex.compiler.plugins import ( + ApplyStylePlugin, + DefaultCollectorPlugin, + DefaultPagePlugin, + default_page_plugins, +) + + +@dataclasses.dataclass(slots=True) +class FakePage: + route: str + component: Callable[[], Component] + title: str | None = None + description: str | None = None + image: str = "" + meta: tuple[dict[str, Any], ...] = () + + +class WrapperComponent(Component): + tag = "WrapperComponent" + library = "wrapper-lib" + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + return {(20, "NestedWrap"): Fragment.create()} + + +class RootComponent(Component): + tag = "RootComponent" + library = "root-lib" + + slot: Component | None = field(default=None) + + def add_style(self) -> dict[str, Any] | None: + return {"display": "flex"} + + def add_custom_code(self) -> list[str]: + return ["const rootAddedCode = 1;"] + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + return {(10, "Wrap"): WrapperComponent.create()} + + +class ChildComponent(Component): + tag = "ChildComponent" + library = "child-lib" + + def add_style(self) -> dict[str, Any] | None: + return {"align_items": "center"} + + def add_custom_code(self) -> list[str]: + return ["const childAddedCode = 1;"] + + def _get_custom_code(self) -> str | None: + return "const childCustomCode = 1;" + + def _get_hooks(self) -> str | None: + return "const childHook = useChildHook();" + + +class PropComponent(Component): + tag = "PropComponent" + library = "prop-lib" + + def add_custom_code(self) -> list[str]: + return ["const propAddedCode = 1;"] + + def _get_custom_code(self) -> str | None: + return "const propCustomCode = 1;" + + def _get_dynamic_imports(self) -> str | None: + return "dynamic(() => import('prop-lib'))" + + def _get_hooks(self) -> str | None: + return "const propHook = usePropHook();" + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + return {(15, "PropWrap"): Fragment.create()} + + +class StubCompilerPlugin(Plugin): + pass + + +def create_component_tree() -> RootComponent: + return RootComponent.create( + ChildComponent.create(id="child-id", style={"color": "red"}), + slot=PropComponent.create(id="prop-id", style={"opacity": "0.5"}), + style={"margin": "0"}, + ) + + +def page_style() -> ComponentStyle: + return { + RootComponent: {"padding": "1rem"}, + ChildComponent: {"font_size": "12px"}, + PropComponent: {"border": "1px solid green"}, + } + + +def normalize_style(component: BaseComponent) -> dict[str, str]: + assert isinstance(component, Component) + return {key: str(value) for key, value in component.style.items()} + + +def create_compile_context(hooks: CompilerHooks | None = None) -> CompileContext: + return CompileContext(pages=[], hooks=hooks or CompilerHooks()) + + +def collect_page_context( + component: BaseComponent, + *, + plugins: tuple[Any, ...], +) -> PageContext: + page_ctx = PageContext( + name="page", + route="/page", + root_component=component, + ) + hooks = CompilerHooks(plugins=plugins) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page(page_ctx, compile_context=compile_ctx) + + return page_ctx + + +def test_eval_page_uses_first_non_none_result() -> None: + calls: list[str] = [] + page = FakePage(route="/demo", component=lambda: Fragment.create()) + + class NoMatchPlugin(StubCompilerPlugin): + def eval_page( + self, + page_fn: Any, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> None: + del page_fn, page, kwargs + calls.append("no-match") + + class MatchPlugin(StubCompilerPlugin): + def eval_page( + self, + page_fn: Any, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> PageContext: + del kwargs + calls.append("match") + return PageContext( + name="page", + route=page.route, + root_component=page_fn(), + ) + + class UnreachablePlugin(StubCompilerPlugin): + def eval_page( + self, + page_fn: Any, + /, + *, + page: PageDefinition, + **kwargs: Any, + ) -> PageContext: + del page_fn, page, kwargs + calls.append("unreachable") + msg = "eval_page should stop at the first page context" + raise AssertionError(msg) + + hooks = CompilerHooks(plugins=(NoMatchPlugin(), MatchPlugin(), UnreachablePlugin())) + + page_ctx = hooks.eval_page(page.component, page=page, compile_context=None) + + assert page_ctx is not None + assert page_ctx.route == "/demo" + assert calls == ["no-match", "match"] + + +def test_compile_page_runs_plugins_in_registration_order() -> None: + calls: list[str] = [] + page_ctx = PageContext( + name="page", + route="/ordered", + root_component=Fragment.create(), + ) + + class FirstPlugin(StubCompilerPlugin): + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + del page_ctx, kwargs + calls.append("first") + + class SecondPlugin(StubCompilerPlugin): + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + del page_ctx, kwargs + calls.append("second") + + hooks = CompilerHooks(plugins=(FirstPlugin(), SecondPlugin())) + hooks.compile_page(page_ctx, compile_context=None) + + assert calls == ["first", "second"] + + +def test_component_hook_resolution_caches_only_real_overrides() -> None: + class EnterPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del comp, page_context, compile_context, in_prop_tree, stateful_component + + class LeavePlugin(StubCompilerPlugin): + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del ( + comp, + children, + page_context, + compile_context, + in_prop_tree, + stateful_component, + ) + + hooks = CompilerHooks(plugins=(Plugin(), EnterPlugin(), LeavePlugin())) + + assert len(hooks._enter_component_hooks) == 1 + assert len(hooks._leave_component_hooks) == 1 + + +def test_enter_component_skips_inherited_base_plugin_hook( + monkeypatch: pytest.MonkeyPatch, +) -> None: + visited: list[str] = [] + root = RootComponent.create() + + def fail_enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del self, comp, page_context, compile_context, in_prop_tree, stateful_component + msg = "Inherited Plugin.enter_component hook should be skipped." + raise AssertionError(msg) + + monkeypatch.setattr(Plugin, "enter_component", fail_enter_component) + + class RealPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del page_context, compile_context, in_prop_tree, stateful_component + visited.append(type(comp).__name__) + + hooks = CompilerHooks(plugins=(Plugin(), RealPlugin())) + page_ctx = PageContext(name="page", route="/page", root_component=root) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + hooks.compile_component( + root, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert visited == ["RootComponent"] + + +def test_enter_component_skips_inherited_protocol_hook( + monkeypatch: pytest.MonkeyPatch, +) -> None: + visited: list[str] = [] + root = RootComponent.create() + + def fail_enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del self, comp, page_context, compile_context, in_prop_tree, stateful_component + msg = "Inherited CompilerPlugin.enter_component hook should be skipped." + raise AssertionError(msg) + + monkeypatch.setattr(CompilerPlugin, "enter_component", fail_enter_component) + + class ProtocolOnlyPlugin(CompilerPlugin): + pass + + class RealPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del page_context, compile_context, in_prop_tree, stateful_component + visited.append(type(comp).__name__) + + hooks = CompilerHooks(plugins=(ProtocolOnlyPlugin(), RealPlugin())) + page_ctx = PageContext(name="page", route="/page", root_component=root) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + hooks.compile_component( + root, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert visited == ["RootComponent"] + + +def test_compile_component_orders_enter_and_leave_by_plugin() -> None: + events: list[str] = [] + root = RootComponent.create() + + class FirstPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del comp, page_context, compile_context, in_prop_tree, stateful_component + events.append("first:enter") + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del ( + comp, + children, + page_context, + compile_context, + in_prop_tree, + stateful_component, + ) + events.append("first:leave") + + class SecondPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del comp, page_context, compile_context, in_prop_tree, stateful_component + events.append("second:enter") + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del ( + comp, + children, + page_context, + compile_context, + in_prop_tree, + stateful_component, + ) + events.append("second:leave") + + hooks = CompilerHooks(plugins=(FirstPlugin(), SecondPlugin())) + page_ctx = PageContext(name="page", route="/page", root_component=root) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + compiled_root = hooks.compile_component( + root, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert compiled_root is root + assert events == [ + "first:enter", + "second:enter", + "second:leave", + "first:leave", + ] + + +def test_compile_component_traverses_children_before_prop_components() -> None: + visited: list[str] = [] + root = RootComponent.create( + ChildComponent.create(), + slot=PropComponent.create(), + ) + + class VisitPlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> None: + del page_context, compile_context, in_prop_tree, stateful_component + if isinstance(comp, Component): + visited.append(comp.tag or type(comp).__name__) + + hooks = CompilerHooks(plugins=(VisitPlugin(),)) + page_ctx = PageContext(name="page", route="/page", root_component=root) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + hooks.compile_component( + root, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert visited == ["RootComponent", "ChildComponent", "PropComponent"] + + +def test_enter_and_leave_replacements_match_generator_style_behavior() -> None: + child = ChildComponent.create(id="original") + root = RootComponent.create(child) + + class ReplacePlugin(StubCompilerPlugin): + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent | ComponentAndChildren | None: + del page_context, compile_context, stateful_component + if isinstance(comp, RootComponent) and not in_prop_tree: + replacement_child = ChildComponent.create(id="replacement") + return comp, (replacement_child,) + return None + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + stateful_component: StatefulComponent | None = None, + ) -> BaseComponent | ComponentAndChildren | None: + del page_context, compile_context, in_prop_tree, stateful_component + if isinstance(comp, RootComponent): + return Fragment.create(comp), children + return None + + hooks = CompilerHooks(plugins=(ReplacePlugin(),)) + page_ctx = PageContext(name="page", route="/page", root_component=root) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + compiled_root = hooks.compile_component( + root, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert isinstance(compiled_root, Fragment) + assert len(compiled_root.children) == 1 + replacement_child = compiled_root.children[0] + assert isinstance(replacement_child, ChildComponent) + assert str(replacement_child.id) == "replacement" + + +def test_context_lifecycle_and_cleanup() -> None: + compile_ctx = CompileContext(pages=[], hooks=CompilerHooks()) + page_ctx = PageContext( + name="page", + route="/ctx", + root_component=Fragment.create(), + ) + + with pytest.raises(RuntimeError, match="No active CompileContext"): + CompileContext.get() + with pytest.raises( + RuntimeError, match="must be entered with 'with' or 'async with'" + ): + compile_ctx.ensure_context_attached() + + with compile_ctx: + assert CompileContext.get() is compile_ctx + with pytest.raises(RuntimeError, match="No active PageContext"): + PageContext.get() + with page_ctx: + assert CompileContext.get() is compile_ctx + assert PageContext.get() is page_ctx + page_ctx.ensure_context_attached() + with pytest.raises(RuntimeError, match="No active PageContext"): + PageContext.get() + assert CompileContext.get() is compile_ctx + + with pytest.raises(RuntimeError, match="No active CompileContext"): + CompileContext.get() + + with pytest.raises(ValueError, match="boom"), compile_ctx: + msg = "boom" + raise ValueError(msg) + + with pytest.raises(RuntimeError, match="No active CompileContext"): + CompileContext.get() + + +def test_page_context_default_factories_are_isolated() -> None: + page_ctx_a = PageContext( + name="a", + route="/a", + root_component=Fragment.create(), + ) + page_ctx_b = PageContext( + name="b", + route="/b", + root_component=Fragment.create(), + ) + + page_ctx_a.imports.append({"lib-a": [ImportVar(tag="ThingA")]}) + page_ctx_a.module_code["const a = 1;"] = None + page_ctx_a.hooks["hookA"] = None + page_ctx_a.dynamic_imports.add("dynamic-a") + page_ctx_a.refs["refA"] = None + page_ctx_a.app_wrap_components[1, "WrapA"] = Fragment.create() + + assert page_ctx_b.imports == [] + assert page_ctx_b.module_code == {} + assert page_ctx_b.hooks == {} + assert page_ctx_b.dynamic_imports == set() + assert page_ctx_b.refs == {} + assert page_ctx_b.app_wrap_components == {} + + +def test_page_context_helpers_preserve_accumulated_values() -> None: + page_ctx = PageContext( + name="page", + route="/page", + root_component=Fragment.create(), + ) + page_ctx.imports.extend([ + {"lib-a": [ImportVar(tag="ThingA")]}, + {"lib-a": [ImportVar(tag="ThingB")], "lib-b": [ImportVar(tag="ThingC")]}, + ]) + page_ctx.module_code["const first = 1;"] = None + page_ctx.module_code["const second = 2;"] = None + + assert page_ctx.merged_imports() == merge_imports(*page_ctx.imports) + assert page_ctx.merged_imports(collapse=True) == collapse_imports( + merge_imports(*page_ctx.imports) + ) + assert list(page_ctx.custom_code_dict()) == [ + "const first = 1;", + "const second = 2;", + ] + + +def test_base_context_subclasses_initialize_distinct_context_vars() -> None: + class DynamicContext(BaseContext): + pass + + class AnotherDynamicContext(BaseContext): + pass + + assert DynamicContext.__context_var__ is not AnotherDynamicContext.__context_var__ + + +def test_apply_style_plugin_matches_legacy_style_behavior() -> None: + component = create_component_tree() + legacy_component = create_component_tree() + + legacy_component._add_style_recursive(page_style()) + + hooks = CompilerHooks(plugins=(ApplyStylePlugin(style=page_style()),)) + page_ctx = PageContext(name="page", route="/page", root_component=component) + compile_ctx = create_compile_context(hooks) + + with compile_ctx, page_ctx: + hooks.compile_component( + component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + + assert normalize_style(component) == normalize_style(legacy_component) + assert normalize_style(component.children[0]) == normalize_style( + legacy_component.children[0] + ) + assert component.slot is not None + assert legacy_component.slot is not None + assert normalize_style(component.slot) == normalize_style(legacy_component.slot) + + +def test_default_collector_matches_legacy_collectors() -> None: + component = create_component_tree() + page_ctx = collect_page_context( + component, + plugins=(DefaultCollectorPlugin(),), + ) + + assert page_ctx.imports == [component._get_all_imports(collapse=True)] + assert page_ctx.hooks == component._get_all_hooks() + assert "usePropHook" not in "".join(page_ctx.hooks) + assert page_ctx.module_code == component._get_all_custom_code() + assert page_ctx.dynamic_imports == component._get_all_dynamic_imports() + assert page_ctx.refs == component._get_all_refs() + assert page_ctx.refs == { + format_utils.format_ref("child-id"): None, + format_utils.format_ref("prop-id"): None, + } + assert ( + page_ctx.app_wrap_components.keys() + == component._get_all_app_wrap_components().keys() + ) + + +def test_default_page_plugins_are_minimal_and_ordered() -> None: + plugins = default_page_plugins(style=page_style()) + + assert len(plugins) == 3 + assert isinstance(plugins[0], DefaultPagePlugin) + assert isinstance(plugins[1], ApplyStylePlugin) + assert isinstance(plugins[2], DefaultCollectorPlugin) + + +def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: + page = FakePage(route="/demo", component=create_component_tree) + compile_ctx = CompileContext( + pages=[page], + hooks=CompilerHooks(plugins=default_page_plugins(style=page_style())), + ) + + with compile_ctx: + compiled_pages = compile_ctx.compile() + + assert compiled_pages is compile_ctx.compiled_pages + assert list(compiled_pages) == ["/demo"] + + page_ctx = compiled_pages["/demo"] + assert isinstance(page_ctx.root_component, Component) + assert page_ctx.name == "create_component_tree" + assert page_ctx.route == "/demo" + assert page_ctx.frontend_imports == page_ctx.merged_imports(collapse=True) + assert compile_ctx.all_imports == page_ctx.frontend_imports + assert page_ctx.output_path is not None + assert page_ctx.output_code is not None + assert page_ctx.imports == [page_ctx.root_component._get_all_imports(collapse=True)] + assert page_ctx.hooks == page_ctx.root_component._get_all_hooks() + assert page_ctx.module_code == page_ctx.root_component._get_all_custom_code() + assert ( + page_ctx.dynamic_imports == page_ctx.root_component._get_all_dynamic_imports() + ) + assert page_ctx.refs == page_ctx.root_component._get_all_refs() + assert ( + page_ctx.app_wrap_components.keys() + == page_ctx.root_component._get_all_app_wrap_components().keys() + ) + + legacy_component = compiler.compile_unevaluated_page( + page.route, + UnevaluatedPage( + component=page.component, + route=page.route, + title=page.title, + description=page.description, + image=page.image, + on_load=None, + meta=page.meta, + context={}, + ), + page_style(), + None, + ) + expected_output = compiler.compile_page(page.route, legacy_component)[1] + assert page_ctx.output_code == expected_output + + +def test_compile_context_rejects_duplicate_routes() -> None: + pages = [ + FakePage(route="/duplicate", component=lambda: Fragment.create()), + FakePage(route="/duplicate", component=lambda: Fragment.create()), + ] + compile_ctx = CompileContext( + pages=pages, + hooks=CompilerHooks(plugins=(DefaultPagePlugin(),)), + ) + + with ( + compile_ctx, + pytest.raises( + RuntimeError, + match="Duplicate compiled page route", + ), + ): + compile_ctx.compile() + + +def test_compile_context_requires_attached_context() -> None: + compile_ctx = CompileContext( + pages=[], + hooks=CompilerHooks(), + ) + + with pytest.raises( + RuntimeError, match="must be entered with 'with' or 'async with'" + ): + compile_ctx.compile() diff --git a/tests/units/test_environment.py b/tests/units/test_environment.py index ab1b805a4d1..65369999df0 100644 --- a/tests/units/test_environment.py +++ b/tests/units/test_environment.py @@ -12,7 +12,6 @@ from reflex_core.environment import ( EnvironmentVariables, EnvVar, - ExecutorType, ExistingPath, PerformanceMode, SequenceOptions, @@ -408,47 +407,6 @@ class TestEnv: assert env_var_instance.default == "default" -class TestExecutorType: - """Test the ExecutorType enum and related functionality.""" - - def test_executor_type_values(self): - """Test ExecutorType enum values.""" - assert ExecutorType.THREAD.value == "thread" - assert ExecutorType.PROCESS.value == "process" - assert ExecutorType.MAIN_THREAD.value == "main_thread" - - def test_get_executor_main_thread_mode(self): - """Test executor selection in main thread mode.""" - with ( - patch.object( - environment.REFLEX_COMPILE_EXECUTOR, - "get", - return_value=ExecutorType.MAIN_THREAD, - ), - patch.object( - environment.REFLEX_COMPILE_PROCESSES, "get", return_value=None - ), - patch.object(environment.REFLEX_COMPILE_THREADS, "get", return_value=None), - ): - executor = ExecutorType.get_executor_from_environment() - - # Test the main thread executor functionality - with executor: - future = executor.submit(lambda x: x * 2, 5) - assert future.result() == 10 - - def test_get_executor_returns_executor(self): - """Test that get_executor_from_environment returns an executor.""" - # Test with default values - should return some kind of executor - executor = ExecutorType.get_executor_from_environment() - assert executor is not None - - # Test that we can use it as a context manager - with executor: - future = executor.submit(lambda: "test") - assert future.result() == "test" - - class TestUtilityFunctions: """Test utility functions.""" From b425f014963ed6b2a8c457ec8921198de2e2c35e Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 00:24:18 +0500 Subject: [PATCH 02/31] Fix memo component ordering and Var-backed page title handling Move memo component compilation after app_root resolution so app-wrap components are included. Fix DefaultPagePlugin to preserve Var-backed titles instead of replacing them with the default string. --- reflex/compiler/compiler.py | 24 +++++++++--------- reflex/compiler/plugins/builtin.py | 8 ++++-- tests/units/compiler/test_plugins.py | 37 ++++++++++++++++++++++++++-- tests/units/test_app.py | 22 +++++++++++++++++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index eb4571aff48..5a26405095d 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1085,18 +1085,6 @@ def compile_app( ] all_imports = compile_ctx.all_imports - ( - memo_components_output, - memo_components_result, - memo_components_imports, - ) = compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), - tuple(EXPERIMENTAL_MEMOS.values()), - ) - compile_results.append((memo_components_output, memo_components_result)) - all_imports = utils.merge_imports(all_imports, memo_components_imports) - progress.advance(task) - if ( code_uses_state_contexts(compile_ctx.stateful_components_code) and app._state is None @@ -1117,6 +1105,18 @@ def compile_app( app_root = app._app_root(app_wrappers) all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) + ( + memo_components_output, + memo_components_result, + memo_components_imports, + ) = compile_memo_components( + dict.fromkeys(CUSTOM_COMPONENTS.values()), + tuple(EXPERIMENTAL_MEMOS.values()), + ) + compile_results.append((memo_components_output, memo_components_result)) + all_imports = utils.merge_imports(all_imports, memo_components_imports) + progress.advance(task) + compile_results.append( compile_document_root( app.head_components, diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index de72755fe49..34f98cf3fa3 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -53,9 +53,13 @@ def eval_page( component = compiler.into_component(page_fn) component = Fragment.create(component) + title = getattr(page, "title", None) meta_args = { - "title": getattr(page, "title", None) - or make_default_page_title(get_config().app_name, page.route), + "title": ( + title + if title is not None + else make_default_page_title(get_config().app_name, page.route) + ), "image": getattr(page, "image", ""), "meta": getattr(page, "meta", ()), } diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 6cf9ed252a3..975e25a0150 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -25,6 +25,7 @@ ) from reflex_core.utils import format as format_utils from reflex_core.utils.imports import ImportVar, collapse_imports, merge_imports +from reflex_core.vars.base import Var from reflex.app import UnevaluatedPage from reflex.compiler import compiler @@ -40,8 +41,8 @@ class FakePage: route: str component: Callable[[], Component] - title: str | None = None - description: str | None = None + title: Var | str | None = None + description: Var | str | None = None image: str = "" meta: tuple[dict[str, Any], ...] = () @@ -790,6 +791,38 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: assert page_ctx.output_code == expected_output +def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> None: + page = UnevaluatedPage( + component=lambda: Fragment.create(), + route="/var-title", + title=Var(_js_expr="pageTitle", _var_type=str), + description=None, + image="", + on_load=None, + meta=(), + context={}, + ) + hooks = CompilerHooks(plugins=(DefaultPagePlugin(),)) + compile_ctx = create_compile_context(hooks) + + with compile_ctx: + page_ctx = hooks.eval_page( + page.component, + page=page, + compile_context=compile_ctx, + ) + + assert page_ctx is not None + + legacy_component = compiler.compile_unevaluated_page( + page.route, + page, + None, + None, + ) + assert page_ctx.root_component.render() == legacy_component.render() + + def test_compile_context_rejects_duplicate_routes() -> None: pages = [ FakePage(route="/duplicate", component=lambda: Fragment.create()), diff --git a/tests/units/test_app.py b/tests/units/test_app.py index d34cb93283d..a6e5786ca06 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2085,6 +2085,28 @@ def test_app_wrap_compile_theme( assert expected.split(",") == function_app_definition.split(",") +def test_compile_writes_app_wrap_memo_components( + compilable_app: tuple[App, Path], + mocker, +) -> None: + """App-wrap memo components are emitted to the shared components module.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_core.config._get_config", return_value=conf) + app, web_dir = compilable_app + + app.add_page(rx.box("Index"), route="/") + app._compile() + + components_js = ( + web_dir + / constants.Dirs.UTILS + / f"{constants.PageNames.COMPONENTS}{constants.Ext.JSX}" + ).read_text() + + assert "export const DefaultOverlayComponents" in components_js + assert "export const MemoizedToastProvider" in components_js + + @pytest.mark.parametrize( "react_strict_mode", [True, False], From 2ab0557df6c78935709b5558a7f51cc084477715 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 00:42:14 +0500 Subject: [PATCH 03/31] Fix app wrap component collection for stateful components Move _get_app_wrap_components collection outside the `if stateful_component is None` guard so that app wrap components (e.g. UploadFilesProvider) are collected even when a component is wrapped as a stateful component. Add test verifying upload pages correctly emit UploadFilesProvider in the app root. --- pyi_hashes.json | 6 +----- reflex/compiler/plugins/builtin.py | 28 +++++++++++++++------------- tests/units/test_app.py | 26 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 39121b1c2f2..0967ef424bc 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1 @@ -{ - "reflex/__init__.pyi": "7696c38fd9c04a598518b49c5185c414", - "reflex/components/__init__.pyi": "55bb242d5e5428db329b88b4923c2ba5", - "reflex/experimental/memo.pyi": "d16eccf33993c781e2f8bc2dd8bbd4d4" -} +{} diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 34f98cf3fa3..7afb146682a 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -214,14 +214,15 @@ def enter_component( if stateful_component is None: self._collect_component_hooks(page_context.hooks, comp) - if ( - type(comp)._get_app_wrap_components - is not Component._get_app_wrap_components - ): - self._collect_app_wrap_components( - page_context.app_wrap_components, - comp, - ) + + if ( + type(comp)._get_app_wrap_components + is not Component._get_app_wrap_components + ): + self._collect_app_wrap_components( + page_context.app_wrap_components, + comp, + ) if (dynamic_import := comp._get_dynamic_imports()) is not None: page_context.dynamic_imports.add(dynamic_import) @@ -322,11 +323,12 @@ def enter_component( if stateful_component is None: collect_component_hooks(hooks, comp) - if ( - type(comp)._get_app_wrap_components - is not base_get_app_wrap_components - ): - collect_app_wrap_components(app_wrap_components, comp) + + if ( + type(comp)._get_app_wrap_components + is not base_get_app_wrap_components + ): + collect_app_wrap_components(app_wrap_components, comp) dynamic_import = comp._get_dynamic_imports() if dynamic_import is not None: diff --git a/tests/units/test_app.py b/tests/units/test_app.py index a6e5786ca06..f33f7f15559 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2107,6 +2107,32 @@ def test_compile_writes_app_wrap_memo_components( assert "export const MemoizedToastProvider" in components_js +def test_compile_writes_upload_files_provider_app_wrap( + compilable_app: tuple[App, Path], + mocker, +) -> None: + """Upload pages emit the UploadFilesProvider app wrap into the app root.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_core.config._get_config", return_value=conf) + app, web_dir = compilable_app + + app.add_page( + lambda: rx.upload.root( + rx.vstack( + rx.button("Select File"), + rx.text("Drag and drop files here or click to select files"), + ), + ), + route="/", + ) + app._compile() + + root_js = web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + root_contents = root_js.read_text() + + assert "UploadFilesProvider" in root_contents + + @pytest.mark.parametrize( "react_strict_mode", [True, False], From 18319ea872eea0c541dd53466bc07f39ac68f4b5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 01:31:02 +0500 Subject: [PATCH 04/31] pyi hashes --- pyi_hashes.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 0967ef424bc..d9f712e64b3 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1 +1,3 @@ -{} +{ + "reflex/__init__.pyi": "7696c38fd9c04a598518b49c5185c414" +} From db4059c1d682c30a25758c177c4204d19b9caede Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 01:36:33 +0500 Subject: [PATCH 05/31] pyi hashes --- pyi_hashes.json | 123 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index d9f712e64b3..62611f50a6d 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,3 +1,124 @@ { - "reflex/__init__.pyi": "7696c38fd9c04a598518b49c5185c414" + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "a252d3efb9c621216c3ac32327158a83", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "2ae0bc697886c5a735afbe232a84f022", + "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "e4f253225cf70b62900e25d0a5c16436", + "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "407342f78a72e87489c8b22e40de68b9", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "8a0b6dcdf622b96be65311b7803c8ce9", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "aa734326f57b0fee9caed75bd318762e", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "8edb8967aa628329c4d1b7cfa3705f3a", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "c02999fa5d121904a242a83d2221f069", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "ba9a750fa1036dd4f454e7f3235aa4aa", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "fbae966c13c0da651a1e35f7045799c1", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "9c1df9038ff6394cac77dbac6b3175c5", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "434ca63fb809077642112d53879380f5", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "4002f8ac81d1b38177c3b837cbc3b44d", + "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "3c22950d97f6017b8e6cc6a6c83cb4b3", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "3f8b625f5b38a9351b01201c7adb2ca0", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "b2e4b26b13f33d8900550fedd2d5f447", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "4164c841934cab71b1c4b132d15663f5", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "d01e9934bcfd81b5fc969d82e362ac20", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "3bf7bee5665293f7583009f651ea3cb1", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "7209d1607545e412ed38dbe2a129321c", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "8241c75ca16a0960b7dea6d6e7aff52e", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "73e38c074d7e6ca2fda8eaad820f177e", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "4407cecb1825dc359bcc7b2bea011a8e", + "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "aaab42816119ac0f308841dc5482b3f1", + "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "e27fddec8de079db37d6699e136411d1", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "6add8b77380ea3702031b07330fc7d60", + "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "721b328e94510f8328728be1657abbb8", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c427fcd82fc6ccf86b4d2b5c4756426", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "5912179a169da4dc3b152042558be2cf", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "6bf366f345e14a556dbb3c0f230e1355", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f8d2a995e488ebc5e8633977151758ce", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "20d803fcc05d4c378547ceaa0e1bcc70", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "1cf906cbc2751f87adbcd85e03b72d2e", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "b4b5bb69e6ce8d08c0df51301e132af4", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "4ce119b25459a01d128bdb5b79b0d128", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "9e58353a97dc006d37d2c7c50506fac4", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "3325f8a4af0aadb70cbfc50558e2f3b2", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "d77a80f688b29d2e1048007172d2b65f", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "caa83be6f97faa95588bfa9ae9e9331e", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "32880736442800061a39ce4b55267eaf", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "e55c023c9ecc907321f163955f4c4875", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "bacf19a5b6904281d7238dbd51e6fc1c", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "b997bdd994844f0e6ca923bbb2dc34a1", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "65a93d778a0fde06975dac9244f51bb3", + "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "4843dd071acb073dc30028322c3d4023", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bc25cae0eca01c8684443d5dfd7b6455", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "6dd30847af62ad7d50d5c5daf6c4a1d7", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "6c12ef3d9f82926bf17d410b774d56f5", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "810fa8c626b79035cdbb04f43b5bc5ad", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cb67e835f9be41f70ee2bae0f8c0a764", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "ba31009535c078df0bc5a26bce6dfd2b", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "2356caa9e23f9c8888cccbbb41b57985", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "f694033992ef188f2da04e865d5a7d77", + "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "5d9a06872953d3e3df99e1ff154a4e0c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "d7c20bd180f28fdb4affcba37e2aa1ff", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ba0ff3b00289cd1896e327fa2be99563", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "2b0f9f472ba6dcc743c2df17642c4a4b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "fad40b463a8ebb0d3ca3900dc8a91679", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0d22969c5407592a0bb36768e149f2b5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "6a6e9b8f6ca3428c45d62bd0e7f94693", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "d96b2048ae17a558d9eb3378ae98524e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "1c7f518e1881e98614eadff952da0844", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "be245c1c3796f695ac4b2d77c3b88a3a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "b56fa19913ed15d9e630951e70479b36", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "f33d86a3bb176e3144570198ce5f93ae", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "10af49cf574b738d616803df2c055ad0", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "3edbddceb585fd80d9e7959977ff276e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "0a06f6fa5cf8a2590c302f618451ca65", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "82508b83193afde0b3bc06911cb78f87", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "8f21ba52183221d4cf0b8beaacd8e006", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "b167c32571142878305d98c0bd656b09", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "8009f36c543c1407e2aa7ead41178ceb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "3814bb2950e2bcc454d186d50d123e9f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "31af9b53ec38736ab7457ea731642869", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "2ca6dfe4f9e00f2647f0ad4fd131e6d3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "7a874fa512ce2d8a490aa41531f5814b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "465a6d6e9525ac909b4f193d2d788682", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "ddb2835ecbeaf90681e4030a14d74604", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "d5333b59e6ba9ad30923d2b60d0e382e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "6b2d881a8ecdf4dd169b341418a703db", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "79764047f53543d673d6e1b2c929d9b8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "f71a320b02ac8f1d6db07b9198b296ec", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "f8420b5196edb74275d2119d780d0031", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "574407d03b311ca9cdf0f98ab53a6fbe", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "9e26688af77fab944635e16e0bf7283f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "3477cc5e00146eaa2cde5d35f9459ad6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "37e0c8dc43c5a24bdba03429e3ca9052", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "c910ebd02d7a78627f884e3431426552", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ead639a106a76cc0e3fd2c8f093f9f23", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "1a03d9525a1544816392067499c3354d", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "c2fbd8547de4993e03017844e8c4b477", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "4545b70fd0802f19993419ab0163d595", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "417e490adc15e93dc2cdb854ee0361d2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "3195e198d92ff43644a09c277303b83b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "69d12b6c918a476ac4557f42fef73c27", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "88880d197ff7347ec7d3f81d6e57de8e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "f329387a5d4a988bc195e6a487ff44db", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "855c9d0c3c2e79e7d3811cfec74d6379", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "bd2c31d4e3d61743b72327f071969e05", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "eee53b418ff0e0660c8cf9d8a0a59386", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "1aa57142797597d65d840eb1d3cc7de7", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "c774f0a1384f983e6d73bde603c341ca", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "894dcd5945123c1c8aa34cb77602fead", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "3926877c04f74fc2acf4a398bee9da06", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "305a8932078e4af48e44489e7ce74060", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "fad43053747fb84229cc35296c7028b5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "5901c7202a5b135f60cc1407878b4859", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "d567c1242672d125015920f7ae6e6999", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "76f100da40d0e18ad4f7b3387dec1d4a", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "a981a6031015c3a384e6255be88885f1", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "1f66ea4fa34e8a8fa7473d312daf84b8", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "8c1ea5bf4ec27ec6ff2dce462021b094", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "2610c28416f80e2254bd10dde8c29bdf", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "597b9eb86c57f5293c13c128fb972c27", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "303d4b1dc72c08339154907b9b095365", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "6e9371bddea95f8e2491d9b3c7e250cd", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1ce679c002336c7bdbdd6c8ff6f2413c", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "1b92135de4ea79cb7d94eaaec55b9ab7", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f09c503c4ab880c13c13d6fa67d708b8", + "reflex/__init__.pyi": "7696c38fd9c04a598518b49c5185c414", + "reflex/components/__init__.pyi": "55bb242d5e5428db329b88b4923c2ba5", + "reflex/experimental/memo.pyi": "d16eccf33993c781e2f8bc2dd8bbd4d4" } From 94f0d5f9bee2a24dfcd7cb12980dae2333329cd7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 01:54:49 +0500 Subject: [PATCH 06/31] added comparison benchmark --- tests/benchmarks/test_compilation.py | 31 ++++++++++++++++++++++++++++ tests/benchmarks/test_evaluate.py | 9 ++++++++ 2 files changed, 40 insertions(+) diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index e1188ed081b..e6922534a00 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -3,6 +3,12 @@ from reflex.compiler.compiler import _compile_page, _compile_stateful_components +from .hotspots import ( + compile_page_full_context, + compile_page_single_pass, + get_all_imports_single_pass, +) + def import_templates(): # Importing the templates module to avoid the import time in the benchmark @@ -15,6 +21,24 @@ def test_compile_page(evaluated_page: Component, benchmark: BenchmarkFixture): benchmark(lambda: _compile_page(evaluated_page)) +def test_compile_page_single_pass( + evaluated_page: Component, + benchmark: BenchmarkFixture, +): + import_templates() + + benchmark(lambda: compile_page_single_pass(evaluated_page)) + + +def test_compile_page_full_context( + unevaluated_page, + benchmark: BenchmarkFixture, +): + import_templates() + + benchmark(lambda: compile_page_full_context(unevaluated_page)) + + def test_compile_stateful(evaluated_page: Component, benchmark: BenchmarkFixture): import_templates() @@ -23,3 +47,10 @@ def test_compile_stateful(evaluated_page: Component, benchmark: BenchmarkFixture def test_get_all_imports(evaluated_page: Component, benchmark: BenchmarkFixture): benchmark(lambda: evaluated_page._get_all_imports()) + + +def test_get_all_imports_single_pass( + evaluated_page: Component, + benchmark: BenchmarkFixture, +): + benchmark(lambda: get_all_imports_single_pass(evaluated_page)) diff --git a/tests/benchmarks/test_evaluate.py b/tests/benchmarks/test_evaluate.py index d12f9facf79..ab7da29785a 100644 --- a/tests/benchmarks/test_evaluate.py +++ b/tests/benchmarks/test_evaluate.py @@ -3,8 +3,17 @@ from pytest_codspeed import BenchmarkFixture from reflex_core.components.component import Component +from .hotspots import evaluate_page_single_pass + def test_evaluate_page( unevaluated_page: Callable[[], Component], benchmark: BenchmarkFixture ): benchmark(unevaluated_page) + + +def test_evaluate_page_single_pass( + unevaluated_page: Callable[[], Component], + benchmark: BenchmarkFixture, +): + benchmark(lambda: evaluate_page_single_pass(unevaluated_page)) From e64a09112346c518707264bca5aac247329a9bb0 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 02:13:08 +0500 Subject: [PATCH 07/31] Replace global StatefulComponent cache with compile-scoped cache Remove the class-level tag_to_stateful_component dict and instead thread a compile-scoped stateful_component_cache through compile_from() and create(). This prevents stale cache entries from leaking between independent compilation runs. Also collect imports and app_wrap_components from root components in CompileContext so stateful component libraries and providers (e.g. UploadFilesProvider) are properly propagated. Update benchmarks to inline helpers using the new plugin API and add tests covering shared stateful components across pages and cache isolation between runs. --- .../src/reflex_core/components/component.py | 59 ++++++++----- .../src/reflex_core/plugins/compiler.py | 23 ++++- reflex/compiler/compiler.py | 9 +- tests/benchmarks/fixtures.py | 10 +++ tests/benchmarks/test_compilation.py | 64 ++++++++++++-- tests/benchmarks/test_evaluate.py | 9 +- tests/units/compiler/test_plugins.py | 88 ++++++++++++++++++- tests/units/components/test_component.py | 11 ++- 8 files changed, 235 insertions(+), 38 deletions(-) diff --git a/packages/reflex-core/src/reflex_core/components/component.py b/packages/reflex-core/src/reflex_core/components/component.py index f17516d3b39..bb98724169f 100644 --- a/packages/reflex-core/src/reflex_core/components/component.py +++ b/packages/reflex-core/src/reflex_core/components/component.py @@ -888,9 +888,7 @@ def _post_init(self, *args, **kwargs): # Get the passed type and the var type. passed_type = kwargs[key]._var_type - expected_type = types.get_args( - types.get_field_type(type(self), key) - )[0] + expected_type = get_args(types.get_field_type(type(self), key))[0] except TypeError: # If it is not a valid var, check the base types. passed_type = type(value) @@ -2390,9 +2388,6 @@ class StatefulComponent(BaseComponent): was created with. """ - # A lookup table to caching memoized component instances. - tag_to_stateful_component: ClassVar[dict[str, StatefulComponent]] = {} - # Reference to the original component that was memoized into this component. component: Component = field( default_factory=Component, is_javascript_property=False @@ -2415,11 +2410,17 @@ class StatefulComponent(BaseComponent): ) @classmethod - def create(cls, component: Component) -> StatefulComponent | None: + def create( + cls, + component: Component, + *, + stateful_component_cache: dict[str, StatefulComponent] | None = None, + ) -> StatefulComponent | None: """Create a stateful component from a component. Args: component: The component to memoize. + stateful_component_cache: Compile-scoped cache of memoized components. Returns: The stateful component or None if the component should not be memoized. @@ -2469,20 +2470,20 @@ def create(cls, component: Component) -> StatefulComponent | None: if tag_name is None: return None - # Look up the tag in the cache - stateful_component = cls.tag_to_stateful_component.get(tag_name) + cache = ( + stateful_component_cache if stateful_component_cache is not None else {} + ) + # Look up the tag in the compile-scoped cache. + stateful_component = cache.get(tag_name) if stateful_component is None: memo_trigger_hooks = cls._fix_event_triggers(component) - # Set the stateful component in the cache for the given tag. - stateful_component = cls.tag_to_stateful_component.setdefault( - tag_name, - cls( - children=component.children, - component=component, - tag=tag_name, - memo_trigger_hooks=memo_trigger_hooks, - ), + stateful_component = cls( + children=component.children, + component=component, + tag=tag_name, + memo_trigger_hooks=memo_trigger_hooks, ) + cache[tag_name] = stateful_component # Bump the reference count -- multiple pages referencing the same component # will result in writing it to a common file. stateful_component.references += 1 @@ -2791,23 +2792,39 @@ def __str__(self) -> str: return _compile_component(self) @classmethod - def compile_from(cls, component: BaseComponent) -> BaseComponent: + def compile_from( + cls, + component: BaseComponent, + *, + stateful_component_cache: dict[str, StatefulComponent] | None = None, + ) -> BaseComponent: """Walk through the component tree and memoize all stateful components. Args: component: The component to memoize. + stateful_component_cache: Compile-scoped cache of memoized components. Returns: The memoized component tree. """ + stateful_component_cache = ( + stateful_component_cache if stateful_component_cache is not None else {} + ) if isinstance(component, Component): if component._memoization_mode.recursive: # Recursively memoize stateful children (default). component.children = [ - cls.compile_from(child) for child in component.children + cls.compile_from( + child, + stateful_component_cache=stateful_component_cache, + ) + for child in component.children ] # Memoize this component if it depends on state. - stateful_component = cls.create(component) + stateful_component = cls.create( + component, + stateful_component_cache=stateful_component_cache, + ) if stateful_component is not None: return stateful_component return component diff --git a/packages/reflex-core/src/reflex_core/plugins/compiler.py b/packages/reflex-core/src/reflex_core/plugins/compiler.py index 13471eb2482..b7ba9dfa45c 100644 --- a/packages/reflex-core/src/reflex_core/plugins/compiler.py +++ b/packages/reflex-core/src/reflex_core/plugins/compiler.py @@ -1009,6 +1009,7 @@ def compile( self.stateful_routes.clear() self.stateful_components_path = compiler.utils.get_stateful_components_path() self.stateful_components_code = "" + stateful_component_cache: dict[str, StatefulComponent] = {} overlay_component: Component | None = None if ( @@ -1053,8 +1054,28 @@ def compile( overlay_component, ) + if isinstance(page_ctx.root_component, StatefulComponent): + self.all_imports = merge_imports( + self.all_imports, + page_ctx.root_component._get_all_imports(), + ) + self.app_wrap_components.update( + page_ctx.root_component.component._get_all_app_wrap_components() + ) + elif isinstance(page_ctx.root_component, Component): + self.all_imports = merge_imports( + self.all_imports, + page_ctx.root_component._get_all_imports(), + ) + self.app_wrap_components.update( + page_ctx.root_component._get_all_app_wrap_components() + ) + page_ctx.root_component = ( - StatefulComponent.compile_from(page_ctx.root_component) + StatefulComponent.compile_from( + page_ctx.root_component, + stateful_component_cache=stateful_component_cache, + ) or page_ctx.root_component ) self.compiled_pages[page_ctx.route] = page_ctx diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 5a26405095d..afe4b2f8252 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -681,10 +681,17 @@ def compile_stateful_components( """ output_path = utils.get_stateful_components_path() + stateful_component_cache: dict[str, StatefulComponent] = {} page_components = [] for page in pages: # Compile the stateful components - page_component = StatefulComponent.compile_from(page) or page + page_component = ( + StatefulComponent.compile_from( + page, + stateful_component_cache=stateful_component_cache, + ) + or page + ) progress_function() page_components.append(page_component) diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index c20ac177660..d94fa9fccb9 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -1,8 +1,10 @@ +from collections.abc import Callable from dataclasses import dataclass from typing import cast import pytest from pydantic import BaseModel +from reflex_core.components.component import Component import reflex as rx @@ -221,6 +223,14 @@ class NestedElement(BaseModel): value: list[int] +@dataclass(frozen=True, slots=True) +class BenchmarkPage: + """Minimal page definition for compiler benchmark helpers.""" + + route: str + component: Callable[[], Component] + + class BenchmarkState(rx.State): """State for the benchmark.""" diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index e6922534a00..99445b43626 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -1,13 +1,12 @@ from pytest_codspeed import BenchmarkFixture -from reflex_core.components.component import Component +from reflex_core.components.component import Component, StatefulComponent +from reflex_core.plugins import CompileContext, CompilerHooks, PageContext +from reflex.compiler import compiler from reflex.compiler.compiler import _compile_page, _compile_stateful_components +from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins -from .hotspots import ( - compile_page_full_context, - compile_page_single_pass, - get_all_imports_single_pass, -) +from .fixtures import BenchmarkPage def import_templates(): @@ -15,6 +14,49 @@ def import_templates(): import reflex.compiler.templates # noqa: F401 +def _compile_single_pass_page_ctx(component: Component) -> PageContext: + page_ctx = PageContext( + name="benchmark", + route="/benchmark", + root_component=StatefulComponent.compile_from(component) or component, + ) + hooks = CompilerHooks(plugins=(DefaultCollectorPlugin(),)) + compile_ctx = CompileContext(pages=[], hooks=hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page(page_ctx, compile_context=compile_ctx) + + return page_ctx + + +def _compile_page_single_pass(component: Component) -> str: + page_ctx = _compile_single_pass_page_ctx(component) + page_ctx.frontend_imports = page_ctx.merged_imports(collapse=True) + return compiler.compile_page_from_context(page_ctx)[1] + + +def _compile_page_full_context(unevaluated_page) -> str: + page = BenchmarkPage(route="/benchmark", component=unevaluated_page) + compile_ctx = CompileContext( + pages=[page], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + + with compile_ctx: + compiled_pages = compile_ctx.compile() + + output_code = compiled_pages["/benchmark"].output_code + if output_code is None: + msg = "CompileContext did not produce output code for the benchmark page." + raise RuntimeError(msg) + return output_code + + def test_compile_page(evaluated_page: Component, benchmark: BenchmarkFixture): import_templates() @@ -27,7 +69,7 @@ def test_compile_page_single_pass( ): import_templates() - benchmark(lambda: compile_page_single_pass(evaluated_page)) + benchmark(lambda: _compile_page_single_pass(evaluated_page)) def test_compile_page_full_context( @@ -36,7 +78,7 @@ def test_compile_page_full_context( ): import_templates() - benchmark(lambda: compile_page_full_context(unevaluated_page)) + benchmark(lambda: _compile_page_full_context(unevaluated_page)) def test_compile_stateful(evaluated_page: Component, benchmark: BenchmarkFixture): @@ -53,4 +95,8 @@ def test_get_all_imports_single_pass( evaluated_page: Component, benchmark: BenchmarkFixture, ): - benchmark(lambda: get_all_imports_single_pass(evaluated_page)) + benchmark( + lambda: _compile_single_pass_page_ctx(evaluated_page).merged_imports( + collapse=True + ) + ) diff --git a/tests/benchmarks/test_evaluate.py b/tests/benchmarks/test_evaluate.py index ab7da29785a..2da8efec1ac 100644 --- a/tests/benchmarks/test_evaluate.py +++ b/tests/benchmarks/test_evaluate.py @@ -2,8 +2,11 @@ from pytest_codspeed import BenchmarkFixture from reflex_core.components.component import Component +from reflex_core.plugins import CompilerHooks -from .hotspots import evaluate_page_single_pass +from reflex.compiler.plugins import DefaultPagePlugin + +from .fixtures import BenchmarkPage def test_evaluate_page( @@ -16,4 +19,6 @@ def test_evaluate_page_single_pass( unevaluated_page: Callable[[], Component], benchmark: BenchmarkFixture, ): - benchmark(lambda: evaluate_page_single_pass(unevaluated_page)) + hooks = CompilerHooks(plugins=(DefaultPagePlugin(),)) + page = BenchmarkPage(route="/benchmark", component=unevaluated_page) + benchmark(lambda: hooks.eval_page(page.component, page=page)) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 975e25a0150..a6ba3f62396 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -6,6 +6,7 @@ import pytest from reflex_components_core.base.fragment import Fragment +from reflex_core import constants from reflex_core.components.component import ( BaseComponent, Component, @@ -13,6 +14,7 @@ StatefulComponent, field, ) +from reflex_core.environment import environment from reflex_core.plugins import ( BaseContext, CompileContext, @@ -25,7 +27,8 @@ ) from reflex_core.utils import format as format_utils from reflex_core.utils.imports import ImportVar, collapse_imports, merge_imports -from reflex_core.vars.base import Var +from reflex_core.vars import VarData +from reflex_core.vars.base import LiteralVar, Var from reflex.app import UnevaluatedPage from reflex.compiler import compiler @@ -111,10 +114,27 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]: return {(15, "PropWrap"): Fragment.create()} +class SharedLibraryComponent(Component): + tag = "SharedLibraryComponent" + library = "react-moment" + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + return {(25, "SharedLibraryWrap"): Fragment.create()} + + class StubCompilerPlugin(Plugin): pass +SHARED_STATEFUL_VAR = LiteralVar.create("shared")._replace( + merge_var_data=VarData( + hooks={"useSharedStatefulValue": None}, + state="SharedState", + ) +) + + def create_component_tree() -> RootComponent: return RootComponent.create( ChildComponent.create(id="child-id", style={"color": "red"}), @@ -123,6 +143,10 @@ def create_component_tree() -> RootComponent: ) +def create_shared_stateful_component() -> SharedLibraryComponent: + return SharedLibraryComponent.create(SHARED_STATEFUL_VAR) + + def page_style() -> ComponentStyle: return { RootComponent: {"padding": "1rem"}, @@ -757,7 +781,10 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: assert page_ctx.name == "create_component_tree" assert page_ctx.route == "/demo" assert page_ctx.frontend_imports == page_ctx.merged_imports(collapse=True) - assert compile_ctx.all_imports == page_ctx.frontend_imports + compile_ctx_imports = collapse_imports(compile_ctx.all_imports) + for lib, fields in page_ctx.frontend_imports.items(): + assert lib in compile_ctx_imports + assert set(compile_ctx_imports[lib]) >= set(fields) assert page_ctx.output_path is not None assert page_ctx.output_code is not None assert page_ctx.imports == [page_ctx.root_component._get_all_imports(collapse=True)] @@ -853,3 +880,60 @@ def test_compile_context_requires_attached_context() -> None: RuntimeError, match="must be entered with 'with' or 'async with'" ): compile_ctx.compile() + + +def test_compile_context_preserves_shared_stateful_component_imports_and_wraps() -> ( + None +): + previous_mode = environment.REFLEX_ENV_MODE.get() + environment.REFLEX_ENV_MODE.set(constants.Env.PROD) + try: + pages = [ + FakePage(route="/a", component=create_shared_stateful_component), + FakePage(route="/b", component=create_shared_stateful_component), + ] + compile_ctx = CompileContext( + pages=pages, + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + + with compile_ctx: + compile_ctx.compile() + + assert "react-moment" in compile_ctx.all_imports + assert (25, "SharedLibraryWrap") in compile_ctx.app_wrap_components + assert "react-moment" in compile_ctx.stateful_components_code + assert "$/utils/stateful_components" in ( + compile_ctx.compiled_pages["/a"].output_code or "" + ) + finally: + environment.REFLEX_ENV_MODE.set(previous_mode) + + +def test_compile_context_resets_stateful_component_cache_between_runs() -> None: + previous_mode = environment.REFLEX_ENV_MODE.get() + try: + environment.REFLEX_ENV_MODE.set(constants.Env.PROD) + prod_ctx = CompileContext( + pages=[ + FakePage(route="/a", component=create_shared_stateful_component), + FakePage(route="/b", component=create_shared_stateful_component), + ], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with prod_ctx: + prod_ctx.compile() + + environment.REFLEX_ENV_MODE.set(constants.Env.DEV) + dev_ctx = CompileContext( + pages=[FakePage(route="/c", component=create_shared_stateful_component)], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with dev_ctx: + dev_ctx.compile() + + page_ctx = dev_ctx.compiled_pages["/c"] + assert "react-moment" in page_ctx.frontend_imports + assert "$/utils/stateful_components" not in (page_ctx.output_code or "") + finally: + environment.REFLEX_ENV_MODE.set(previous_mode) diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 98df3eb85a7..b699ff2df7a 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -1170,13 +1170,20 @@ def test_stateful_component(test_state: type[TestState]): Args: test_state: A test state. """ + stateful_component_cache: dict[str, StatefulComponent] = {} text_component = rx.text(test_state.num) - stateful_component = StatefulComponent.compile_from(text_component) + stateful_component = StatefulComponent.compile_from( + text_component, + stateful_component_cache=stateful_component_cache, + ) assert isinstance(stateful_component, StatefulComponent) assert stateful_component.tag is not None assert stateful_component.tag.startswith("Text_") assert stateful_component.references == 1 - sc2 = StatefulComponent.compile_from(rx.text(test_state.num)) + sc2 = StatefulComponent.compile_from( + rx.text(test_state.num), + stateful_component_cache=stateful_component_cache, + ) assert isinstance(sc2, StatefulComponent) assert stateful_component.references == 2 assert sc2.references == 2 From 1948394f3300a9ef1c36c7a65ea293af0e673bd7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 21:49:03 +0500 Subject: [PATCH 08/31] fixed benchmarks and cache render and deduplicate functions --- .../src/reflex_core/components/component.py | 12 ++++ reflex/compiler/plugins/builtin.py | 7 ++- tests/benchmarks/fixtures.py | 61 ++++++++++++++++++- tests/benchmarks/test_compilation.py | 40 +++++++++++- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/packages/reflex-core/src/reflex_core/components/component.py b/packages/reflex-core/src/reflex_core/components/component.py index bb98724169f..7755b776153 100644 --- a/packages/reflex-core/src/reflex_core/components/component.py +++ b/packages/reflex-core/src/reflex_core/components/component.py @@ -1323,6 +1323,10 @@ def render(self) -> dict: Returns: The dictionary for template of component. """ + try: + return self._cached_render_result + except AttributeError: + pass tag = self._render() rendered_dict = dict( tag.set( @@ -1330,6 +1334,7 @@ def render(self) -> dict: ) ) self._replace_prop_names(rendered_dict) + self._cached_render_result = rendered_dict return rendered_dict def _replace_prop_names(self, rendered_dict: dict) -> None: @@ -2477,6 +2482,13 @@ def create( stateful_component = cache.get(tag_name) if stateful_component is None: memo_trigger_hooks = cls._fix_event_triggers(component) + if memo_trigger_hooks: + # event_triggers were mutated via shared dict; + # invalidate stale render cache on the top-level component + # so _render_stateful_code re-renders with memoized triggers. + # Children are unaffected and keep their cached results. + with contextlib.suppress(AttributeError): + del component._cached_render_result stateful_component = cls( children=component.children, component=component, diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 7afb146682a..8825f8240e4 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -297,6 +297,7 @@ def _compiler_bind_enter_component( collect_component_custom_code = self._collect_component_custom_code collect_app_wrap_components = self._collect_app_wrap_components base_get_app_wrap_components = Component._get_app_wrap_components + seen_app_wrap_methods: set[object] = set() def enter_component( comp: BaseComponent, @@ -324,10 +325,12 @@ def enter_component( if stateful_component is None: collect_component_hooks(hooks, comp) + app_wrap_method = type(comp)._get_app_wrap_components if ( - type(comp)._get_app_wrap_components - is not base_get_app_wrap_components + app_wrap_method is not base_get_app_wrap_components + and app_wrap_method not in seen_app_wrap_methods ): + seen_app_wrap_methods.add(app_wrap_method) collect_app_wrap_components(app_wrap_components, comp) dynamic_import = comp._get_dynamic_imports() diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index d94fa9fccb9..e2547b975e3 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -1,12 +1,14 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import cast +from typing import Any, cast import pytest from pydantic import BaseModel -from reflex_core.components.component import Component +from reflex_core.components.component import BaseComponent, Component, StatefulComponent +from reflex_core.plugins import CompileContext, PageContext import reflex as rx +from reflex.compiler.plugins import DefaultCollectorPlugin class SideBarState(rx.State): @@ -231,6 +233,61 @@ class BenchmarkPage: component: Callable[[], Component] +@dataclass(frozen=True, slots=True) +class ImportOnlyCollectorPlugin(DefaultCollectorPlugin): + """Collect only imports — same scope as Component._get_all_imports. + + Inherits import collection from DefaultCollectorPlugin but disables + hooks, custom code, app_wrap, and stateful code rendering. + """ + + _compiler_stateful_only_leave_component = False + + def leave_component(self, *_args: Any, **_kwargs: Any) -> None: + """No-op: skip stateful code rendering.""" + + def _compiler_bind_leave_component( + self, *_args: Any, **_kwargs: Any + ) -> Callable[..., None]: + """Return a no-op leave hook.""" + + def _noop(*_a: Any, **_kw: Any) -> None: + pass + + return _noop + + def _compiler_bind_enter_component( + self, + page_context: PageContext, + compile_context: CompileContext, + ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + del compile_context + + frontend_imports = page_context.frontend_imports + extend_imports = self._extend_imports + + def enter_component( + comp: BaseComponent, + in_prop_tree: bool, + stateful_component: StatefulComponent | None, + ) -> None: + del stateful_component + + if isinstance(comp, StatefulComponent): + if comp.rendered_as_shared: + extend_imports(frontend_imports, comp._get_all_imports()) + return + + if not isinstance(comp, Component) or in_prop_tree: + return + + imports = comp._get_imports() + if imports: + extend_imports(frontend_imports, imports) + + return enter_component + + class BenchmarkState(rx.State): """State for the benchmark.""" diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index 99445b43626..59d2a057e09 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -6,7 +6,7 @@ from reflex.compiler.compiler import _compile_page, _compile_stateful_components from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins -from .fixtures import BenchmarkPage +from .fixtures import BenchmarkPage, ImportOnlyCollectorPlugin def import_templates(): @@ -34,6 +34,31 @@ def _compile_single_pass_page_ctx(component: Component) -> PageContext: return page_ctx +def _get_imports_single_pass(component: Component) -> dict: + """Collect only imports via a single-pass walk — comparable to _get_all_imports. + + Returns: + The collapsed import dict for the page. + """ + page_ctx = PageContext( + name="benchmark", + route="/benchmark", + root_component=component, + ) + hooks = CompilerHooks(plugins=(ImportOnlyCollectorPlugin(),)) + compile_ctx = CompileContext(pages=[], hooks=hooks) + + with compile_ctx, page_ctx: + hooks.compile_component( + component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page(page_ctx, compile_context=compile_ctx) + + return page_ctx.frontend_imports + + def _compile_page_single_pass(component: Component) -> str: page_ctx = _compile_single_pass_page_ctx(component) page_ctx.frontend_imports = page_ctx.merged_imports(collapse=True) @@ -95,6 +120,19 @@ def test_get_all_imports_single_pass( evaluated_page: Component, benchmark: BenchmarkFixture, ): + benchmark(lambda: _get_imports_single_pass(evaluated_page)) + + +def test_compile_single_pass_all_artifacts( + evaluated_page: Component, + benchmark: BenchmarkFixture, +): + """Full single-pass collecting all artifacts (imports, hooks, code, app_wrap). + + This is the fair comparison for the total work the old multi-pass approach + did across _get_all_imports + _get_all_hooks + _get_all_custom_code + + _get_all_app_wrap_components. + """ benchmark( lambda: _compile_single_pass_page_ctx(evaluated_page).merged_imports( collapse=True From 1cf49628fde5d362d76776572e35f4b558c98602 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 4 Apr 2026 22:10:08 +0500 Subject: [PATCH 09/31] fix import --- tests/units/test_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 863a312c1d2..4a6c7a81113 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2091,7 +2091,7 @@ def test_compile_writes_app_wrap_memo_components( ) -> None: """App-wrap memo components are emitted to the shared components module.""" conf = rx.Config(app_name="testing") - mocker.patch("reflex_core.config._get_config", return_value=conf) + mocker.patch("reflex_base.config._get_config", return_value=conf) app, web_dir = compilable_app app.add_page(rx.box("Index"), route="/") @@ -2113,7 +2113,7 @@ def test_compile_writes_upload_files_provider_app_wrap( ) -> None: """Upload pages emit the UploadFilesProvider app wrap into the app root.""" conf = rx.Config(app_name="testing") - mocker.patch("reflex_core.config._get_config", return_value=conf) + mocker.patch("reflex_base.config._get_config", return_value=conf) app, web_dir = compilable_app app.add_page( From ddaf2429b897ba459baf6e315b274511fc81d6c6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sun, 5 Apr 2026 15:31:26 +0500 Subject: [PATCH 10/31] fix broken _get_vars cache and add imports/hooks caches Component._get_vars had a dead-code cache path: `getattr(self, "__vars", None)` reads the literal attribute `__vars` but `self.__vars = []` writes to the name-mangled `_Component__vars`. The cache branch was never taken, and even if the name-mangling were fixed the missing `return` after `yield from vars` would have caused duplicate yields on repeated calls. Fix the cache (as `_vars_cache`) with a proper early-return. Extend the same per-instance cache pattern to `_get_imports` and `_get_hooks_internal`, which share the same dependency on `event_triggers` / `_get_vars`. Unify invalidation with the existing render-cache clear in `StatefulComponent.create` so all four caches drop together when `_fix_event_triggers` mutates the component. --- .../src/reflex_base/components/component.py | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 2543d0d3e77..f9913ceb1f0 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1458,11 +1458,16 @@ def _get_vars( Yields: Each var referenced by the component (props, styles, event handlers). """ + # Default-args fast path is cached per instance. Invalidated by + # StatefulComponent.create when _fix_event_triggers mutates event_triggers. + if not include_children and ignore_ids is None: + cached = self.__dict__.get("_vars_cache") + if cached is not None: + yield from cached + return + ignore_ids = ignore_ids or set() - vars: list[Var] | None = getattr(self, "__vars", None) - if vars is not None: - yield from vars - vars = self.__vars = [] + vars: list[Var] = [] # Get Vars associated with event trigger arguments. for _, event_vars in self._get_vars_from_event_triggers(self.event_triggers): vars.extend(event_vars) @@ -1501,7 +1506,6 @@ def _get_vars( if var._get_all_var_data() is not None: vars.append(var) - # Get Vars associated with children. if include_children: for child in self.children: if not isinstance(child, Component) or id(child) in ignore_ids: @@ -1511,7 +1515,11 @@ def _get_vars( include_children=include_children, ignore_ids=ignore_ids ) vars.extend(child_vars) + yield from vars + return + # Freeze and cache the default-args result. + self._vars_cache = tuple(vars) yield from vars def _event_trigger_values_use_state(self) -> bool: @@ -1709,6 +1717,10 @@ def _get_imports(self) -> ParsedImportDict: Returns: The imports needed by the component. """ + cached = self.__dict__.get("_imports_cache") + if cached is not None: + return cached + imports_ = ( {self.library: [self.import_var]} if self.library is not None and self.tag is not None @@ -1736,7 +1748,7 @@ def _get_imports(self) -> ParsedImportDict: imports.parse_imports(item) for item in list_of_import_dict ]) - return imports.merge_parsed_imports( + result = imports.merge_parsed_imports( self._get_dependencies_imports(), self._get_hooks_imports(), imports_, @@ -1744,6 +1756,8 @@ def _get_imports(self) -> ParsedImportDict: *var_imports, *added_import_dicts, ) + self._imports_cache = result + return result def _get_all_imports(self, collapse: bool = False) -> ParsedImportDict: """Get all the libraries and fields that are used by the component and its children. @@ -1840,7 +1854,11 @@ def _get_hooks_internal(self) -> dict[str, VarData | None]: Returns: The internally managed hooks. """ - return { + cached = self.__dict__.get("_hooks_internal_cache") + if cached is not None: + return cached + + result = { **{ str(hook): VarData(position=Hooks.HookPosition.INTERNAL) for hook in [self._get_ref_hook(), self._get_mount_lifecycle_hook()] @@ -1849,6 +1867,8 @@ def _get_hooks_internal(self) -> dict[str, VarData | None]: **self._get_vars_hooks(), **self._get_events_hooks(), } + self._hooks_internal_cache = result + return result def _get_added_hooks(self) -> dict[str, VarData | None]: """Get the hooks added via `add_hooks` method. @@ -2483,12 +2503,18 @@ def create( if stateful_component is None: memo_trigger_hooks = cls._fix_event_triggers(component) if memo_trigger_hooks: - # event_triggers were mutated via shared dict; - # invalidate stale render cache on the top-level component - # so _render_stateful_code re-renders with memoized triggers. + # event_triggers were mutated via shared dict; invalidate + # every derived cache on the top-level component so + # _render_stateful_code sees the memoized triggers. # Children are unaffected and keep their cached results. - with contextlib.suppress(AttributeError): - del component._cached_render_result + for attr in ( + "_cached_render_result", + "_vars_cache", + "_imports_cache", + "_hooks_internal_cache", + ): + with contextlib.suppress(AttributeError): + delattr(component, attr) stateful_component = cls( children=component.children, component=component, From 41204c1cc9902690abbcee194d37b2c85b5d3773 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Mon, 6 Apr 2026 23:12:43 +0500 Subject: [PATCH 11/31] Simplify compiler hooks: merge tree walkers and remove optimization fields Remove the duplicated _compile_component_without_replacements and _compile_component_single_enter_fast_path methods in favor of a single _compile_component_tree walker with inline replacement checks. This eliminates ~200 lines of duplication and removes several optimization-only fields (_regular_leave_component_hook_binders, _stateful_leave_component_hook_binders, _component_hooks_can_replace, _enter_component_hooks, _leave_component_hooks) with negligible benchmark impact. --- .../src/reflex_base/plugins/compiler.py | 393 ++---------------- reflex/compiler/plugins/builtin.py | 3 - tests/units/compiler/test_plugins.py | 4 +- 3 files changed, 37 insertions(+), 363 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 5197c823603..1be726e53e3 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -119,7 +119,6 @@ def enter_component( Returns: An optional replacement component and/or structural children. """ - del comp, page_context, compile_context, in_prop_tree, stateful_component return None def leave_component( @@ -146,14 +145,6 @@ def leave_component( Returns: An optional replacement component and/or structural children. """ - del ( - comp, - children, - page_context, - compile_context, - in_prop_tree, - stateful_component, - ) return None @@ -170,16 +161,6 @@ class CompilerHooks: init=False, repr=False, ) - _enter_component_hooks: tuple[Callable[..., Any], ...] = dataclasses.field( - init=False, - repr=False, - ) - _leave_component_hooks: tuple[tuple[Callable[..., Any], bool], ...] = ( - dataclasses.field( - init=False, - repr=False, - ) - ) _enter_component_hook_binders: tuple[EnterHookBinder, ...] = dataclasses.field( init=False, repr=False, @@ -190,22 +171,6 @@ class CompilerHooks: repr=False, ) ) - _regular_leave_component_hook_binders: tuple[LeaveHookBinder, ...] = ( - dataclasses.field( - init=False, - repr=False, - ) - ) - _stateful_leave_component_hook_binders: tuple[LeaveHookBinder, ...] = ( - dataclasses.field( - init=False, - repr=False, - ) - ) - _component_hooks_can_replace: bool = dataclasses.field( - init=False, - repr=False, - ) def __post_init__(self) -> None: """Resolve the active compiler hook callables once.""" @@ -215,27 +180,16 @@ def __post_init__(self) -> None: "_compile_page_hooks", self._resolve_hooks("compile_page"), ) - enter_hooks: list[Callable[..., Any]] = [] enter_hook_binders: list[EnterHookBinder] = [] - leave_hooks: list[tuple[Callable[..., Any], bool]] = [] leave_hook_binders: list[tuple[LeaveHookBinder, bool]] = [] - component_hooks_can_replace = False for plugin in self.plugins: if ( hook_impl := self._get_hook_impl(plugin, "enter_component") ) is not None: - enter_hooks.append(hook_impl) enter_hook_binders.append( self._get_enter_hook_binder(plugin, hook_impl) ) - component_hooks_can_replace = component_hooks_can_replace or bool( - getattr( - type(plugin), - "_compiler_can_replace_enter_component", - True, - ) - ) if ( hook_impl := self._get_hook_impl(plugin, "leave_component") @@ -247,31 +201,12 @@ def __post_init__(self) -> None: False, ) ) - leave_hooks.append((hook_impl, stateful_only)) leave_hook_binders.append(( self._get_leave_hook_binder(plugin, hook_impl), stateful_only, )) - component_hooks_can_replace = component_hooks_can_replace or bool( - getattr( - type(plugin), - "_compiler_can_replace_leave_component", - True, - ) - ) - reversed_leave_hooks = tuple(reversed(tuple(leave_hooks))) reversed_leave_hook_binders = tuple(reversed(tuple(leave_hook_binders))) - object.__setattr__( - self, - "_leave_component_hooks", - reversed_leave_hooks, - ) - object.__setattr__( - self, - "_enter_component_hooks", - tuple(enter_hooks), - ) object.__setattr__( self, "_enter_component_hook_binders", @@ -282,29 +217,6 @@ def __post_init__(self) -> None: "_leave_component_hook_binders", reversed_leave_hook_binders, ) - object.__setattr__( - self, - "_regular_leave_component_hook_binders", - tuple( - binder - for binder, stateful_only in reversed_leave_hook_binders - if not stateful_only - ), - ) - object.__setattr__( - self, - "_stateful_leave_component_hook_binders", - tuple( - binder - for binder, stateful_only in reversed_leave_hook_binders - if stateful_only - ), - ) - object.__setattr__( - self, - "_component_hooks_can_replace", - component_hooks_can_replace, - ) @staticmethod def _get_hook_impl( @@ -461,238 +373,20 @@ def compile_component( hook_binder(page_context, compile_context) for hook_binder in self._enter_component_hook_binders ) + leave_hooks = tuple( + (hook_binder(page_context, compile_context), stateful_only) + for hook_binder, stateful_only in self._leave_component_hook_binders + ) - if not self._component_hooks_can_replace: - regular_leave_hooks = tuple( - hook_binder(page_context, compile_context) - for hook_binder in self._regular_leave_component_hook_binders - ) - stateful_leave_hooks = tuple( - hook_binder(page_context, compile_context) - for hook_binder in self._stateful_leave_component_hook_binders - ) - - if ( - len(enter_hooks) == 1 - and not regular_leave_hooks - and len(stateful_leave_hooks) <= 1 - ): - return self._compile_component_single_enter_fast_path( - comp, - enter_hook=enter_hooks[0], - stateful_leave_hook=( - stateful_leave_hooks[0] if stateful_leave_hooks else None - ), - in_prop_tree=in_prop_tree, - stateful_component=stateful_component, - ) - - return self._compile_component_without_replacements( - comp, - enter_hooks=enter_hooks, - regular_leave_hooks=regular_leave_hooks, - stateful_leave_hooks=stateful_leave_hooks, - in_prop_tree=in_prop_tree, - stateful_component=stateful_component, - ) - - return self._compile_component_with_replacements( + return self._compile_component_tree( comp, enter_hooks=enter_hooks, - leave_hooks=tuple( - (hook_binder(page_context, compile_context), stateful_only) - for hook_binder, stateful_only in self._leave_component_hook_binders - ), + leave_hooks=leave_hooks, in_prop_tree=in_prop_tree, stateful_component=stateful_component, ) - def _compile_component_without_replacements( - self, - comp: BaseComponent, - /, - *, - enter_hooks: tuple[CompiledEnterHook, ...], - regular_leave_hooks: tuple[CompiledLeaveHook, ...], - stateful_leave_hooks: tuple[CompiledLeaveHook, ...], - in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, - ) -> BaseComponent: - """Walk a component tree when hook plans only observe state. - - Returns: - The compiled component root for this subtree. - """ - - def visit( - current_comp: BaseComponent, - current_in_prop_tree: bool, - current_stateful_component: StatefulComponent | None, - ) -> BaseComponent: - for hook_impl in enter_hooks: - hook_impl( - current_comp, - current_in_prop_tree, - current_stateful_component, - ) - - if isinstance(current_comp, StatefulComponent): - if not current_comp.rendered_as_shared: - compiled_component = cast( - Component, - visit( - current_comp.component, - current_in_prop_tree, - current_comp, - ), - ) - if compiled_component is not current_comp.component: - current_comp.component = compiled_component - - if stateful_leave_hooks: - compiled_children = tuple(current_comp.children) - for hook_impl in stateful_leave_hooks: - hook_impl( - current_comp, - compiled_children, - current_in_prop_tree, - current_stateful_component, - ) - if regular_leave_hooks: - compiled_children = tuple(current_comp.children) - for hook_impl in regular_leave_hooks: - hook_impl( - current_comp, - compiled_children, - current_in_prop_tree, - current_stateful_component, - ) - return current_comp - - updated_children: list[BaseComponent] | None = None - children = current_comp.children - for index, child in enumerate(children): - compiled_child = visit( - child, - current_in_prop_tree, - current_stateful_component, - ) - if updated_children is None: - if compiled_child is child: - continue - updated_children = list(children[:index]) - updated_children.append(compiled_child) - if updated_children is not None: - current_comp.children = updated_children - - if isinstance(current_comp, Component): - for prop_component in current_comp._get_components_in_props(): - visit( - prop_component, - True, - current_stateful_component, - ) - - if regular_leave_hooks: - compiled_children = tuple(current_comp.children) - for hook_impl in regular_leave_hooks: - hook_impl( - current_comp, - compiled_children, - current_in_prop_tree, - current_stateful_component, - ) - - return current_comp - - return visit( - comp, - in_prop_tree, - stateful_component, - ) - - def _compile_component_single_enter_fast_path( - self, - comp: BaseComponent, - /, - *, - enter_hook: CompiledEnterHook, - stateful_leave_hook: CompiledLeaveHook | None, - in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, - ) -> BaseComponent: - """Walk a component tree for the common one-enter-hook fast path. - - Returns: - The compiled component root for this subtree. - """ - - def visit( - current_comp: BaseComponent, - current_in_prop_tree: bool, - current_stateful_component: StatefulComponent | None, - ) -> BaseComponent: - enter_hook( - current_comp, - current_in_prop_tree, - current_stateful_component, - ) - - if isinstance(current_comp, StatefulComponent): - if not current_comp.rendered_as_shared: - compiled_component = cast( - Component, - visit( - current_comp.component, - current_in_prop_tree, - current_comp, - ), - ) - if compiled_component is not current_comp.component: - current_comp.component = compiled_component - - if stateful_leave_hook is not None: - stateful_leave_hook( - current_comp, - tuple(current_comp.children), - current_in_prop_tree, - current_stateful_component, - ) - return current_comp - - updated_children: list[BaseComponent] | None = None - children = current_comp.children - for index, child in enumerate(children): - compiled_child = visit( - child, - current_in_prop_tree, - current_stateful_component, - ) - if updated_children is None: - if compiled_child is child: - continue - updated_children = list(children[:index]) - updated_children.append(compiled_child) - if updated_children is not None: - current_comp.children = updated_children - - if isinstance(current_comp, Component): - for prop_component in current_comp._get_components_in_props(): - visit( - prop_component, - True, - current_stateful_component, - ) - - return current_comp - - return visit( - comp, - in_prop_tree, - stateful_component, - ) - - def _compile_component_with_replacements( + def _compile_component_tree( self, comp: BaseComponent, /, @@ -702,12 +396,11 @@ def _compile_component_with_replacements( in_prop_tree: bool = False, stateful_component: StatefulComponent | None = None, ) -> BaseComponent: - """Walk a component tree while honoring hook replacements. + """Walk a component tree dispatching enter/leave hooks. Returns: The compiled component root for this subtree. """ - apply_replacement = self._apply_replacement def visit_children( children: Sequence[BaseComponent], @@ -742,15 +435,19 @@ def visit( structural_children: tuple[BaseComponent, ...] | None = None for hook_impl in enter_hooks: - compiled_component, structural_children = apply_replacement( + replacement = hook_impl( compiled_component, - structural_children, - hook_impl( - compiled_component, - current_in_prop_tree, - current_stateful_component, - ), + current_in_prop_tree, + current_stateful_component, ) + if replacement is not None: + if isinstance(replacement, tuple): + compiled_component = cast(BaseComponent, replacement[0]) + structural_children = cast( + tuple[BaseComponent, ...], replacement[1] + ) + else: + compiled_component = replacement if isinstance(compiled_component, StatefulComponent): if not compiled_component.rendered_as_shared: @@ -783,23 +480,25 @@ def visit( for hook_impl, stateful_only in leave_hooks: if stateful_only and not is_stateful_component: continue - compiled_component, replacement_children = apply_replacement( + replacement = hook_impl( compiled_component, compiled_children, - hook_impl( - compiled_component, - compiled_children, - current_in_prop_tree, - current_stateful_component, - ), + current_in_prop_tree, + current_stateful_component, ) - if replacement_children is not compiled_children: - assert replacement_children is not None - compiled_children = visit_children( - replacement_children, - current_in_prop_tree, - current_stateful_component, - ) + if replacement is not None: + if isinstance(replacement, tuple): + compiled_component = cast(BaseComponent, replacement[0]) + new_children = cast(tuple[BaseComponent, ...], replacement[1]) + else: + compiled_component = replacement + new_children = compiled_children + if new_children is not compiled_children: + compiled_children = visit_children( + new_children, + current_in_prop_tree, + current_stateful_component, + ) compiled_component.children = list(compiled_children) return compiled_component @@ -810,28 +509,6 @@ def visit( stateful_component, ) - @staticmethod - def _apply_replacement( - comp: BaseComponent, - children: tuple[BaseComponent, ...] | None, - replacement: ComponentReplacement, - ) -> tuple[BaseComponent, tuple[BaseComponent, ...] | None]: - """Apply a plugin replacement to the current component state. - - Args: - comp: The current component. - children: The current structural children. - replacement: The plugin-supplied replacement. - - Returns: - The updated component and structural children pair. - """ - if replacement is None: - return comp, children - if isinstance(replacement, tuple): - return replacement - return replacement, children - @dataclasses.dataclass(kw_only=True) class BaseContext: diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 93724f7f3b0..e87f2935692 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -83,7 +83,6 @@ def eval_page( class ApplyStylePlugin(Plugin): """Apply app-level styles in the descending phase of the walk.""" - _compiler_can_replace_enter_component = False style: ComponentStyle | None = None theme: Component | None = None @@ -173,8 +172,6 @@ def enter_component( class DefaultCollectorPlugin(Plugin): """Collect page artifacts in one fused enter/leave hook pair.""" - _compiler_can_replace_enter_component = False - _compiler_can_replace_leave_component = False _compiler_stateful_only_leave_component = True stateful_custom_code_export: bool = False diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index b5be2de4112..cddfba4b47a 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -315,8 +315,8 @@ def leave_component( hooks = CompilerHooks(plugins=(Plugin(), EnterPlugin(), LeavePlugin())) - assert len(hooks._enter_component_hooks) == 1 - assert len(hooks._leave_component_hooks) == 1 + assert len(hooks._enter_component_hook_binders) == 1 + assert len(hooks._leave_component_hook_binders) == 1 def test_enter_component_skips_inherited_base_plugin_hook( From 733f4e5376f8a87d9836be50efae164e33a2c3d6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Mon, 6 Apr 2026 23:55:54 +0500 Subject: [PATCH 12/31] Fix stateful component imports not included in all_imports _compile_stateful_components now returns its collected imports alongside the rendered code. CompileContext merges these into all_imports after compilation, and no longer calls _get_all_imports on the root component (the single-pass walk already collects structural imports). --- .../src/reflex_base/plugins/compiler.py | 21 ++++++-------- reflex/compiler/compiler.py | 15 ++++++---- tests/units/compiler/test_plugins.py | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 1be726e53e3..b96edf2d432 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -732,18 +732,10 @@ def compile( ) if isinstance(page_ctx.root_component, StatefulComponent): - self.all_imports = merge_imports( - self.all_imports, - page_ctx.root_component._get_all_imports(), - ) self.app_wrap_components.update( page_ctx.root_component.component._get_all_app_wrap_components() ) elif isinstance(page_ctx.root_component, Component): - self.all_imports = merge_imports( - self.all_imports, - page_ctx.root_component._get_all_imports(), - ) self.app_wrap_components.update( page_ctx.root_component._get_all_app_wrap_components() ) @@ -763,11 +755,14 @@ def compile( page_components = [ page_ctx.root_component for page_ctx in self.compiled_pages.values() ] - self.stateful_components_code = ( - compiler._compile_stateful_components(page_components) - if is_prod_mode() - else "" - ) + stateful_imports: ParsedImportDict = {} + if is_prod_mode(): + self.stateful_components_code, stateful_imports = ( + compiler._compile_stateful_components(page_components) + ) + self.all_imports = merge_imports(self.all_imports, stateful_imports) + else: + self.stateful_components_code = "" for page, page_ctx in zip( self.pages, diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index db2f05376d3..0b288a8ad39 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -472,7 +472,7 @@ def _get_shared_components_recursive( def _compile_stateful_components( page_components: list[BaseComponent], -) -> str: +) -> tuple[str, ParsedImportDict]: """Walk the page components and extract shared stateful components. Any StatefulComponent that is shared by more than one page will be rendered @@ -484,7 +484,7 @@ def _compile_stateful_components( page_components: The Components or StatefulComponents to compile. Returns: - The rendered stateful components code. + The rendered stateful components code and imports. """ all_import_dicts = [] rendered_components = {} @@ -502,9 +502,12 @@ def _compile_stateful_components( if rendered_components: _apply_common_imports(all_imports) - return templates.stateful_components_template( - imports=utils.compile_imports(all_imports), - memoized_code="\n".join(rendered_components), + return ( + templates.stateful_components_template( + imports=utils.compile_imports(all_imports), + memoized_code="\n".join(rendered_components), + ), + all_imports, ) @@ -695,7 +698,7 @@ def compile_stateful_components( progress_function() page_components.append(page_component) - code = _compile_stateful_components(page_components) if is_prod_mode() else "" + code = _compile_stateful_components(page_components)[0] if is_prod_mode() else "" return output_path, code, page_components diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index cddfba4b47a..e798de04863 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -114,6 +114,11 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]: return {(15, "PropWrap"): Fragment.create()} +class NoRecursiveImportsComponent(Component): + tag = "NoRecursiveImportsComponent" + library = "no-recursive-imports-lib" + + class SharedLibraryComponent(Component): tag = "SharedLibraryComponent" library = "react-moment" @@ -147,6 +152,10 @@ def create_shared_stateful_component() -> SharedLibraryComponent: return SharedLibraryComponent.create(SHARED_STATEFUL_VAR) +def create_no_recursive_imports_component() -> NoRecursiveImportsComponent: + return NoRecursiveImportsComponent.create() + + def page_style() -> ComponentStyle: return { RootComponent: {"padding": "1rem"}, @@ -818,6 +827,25 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: assert page_ctx.output_code == expected_output +def test_compile_context_does_not_recurse_root_imports() -> None: + page = FakePage( + route="/no-recursive-imports", + component=create_no_recursive_imports_component, + ) + compile_ctx = CompileContext( + pages=[page], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + + with compile_ctx: + compiled_pages = compile_ctx.compile() + + page_ctx = compiled_pages["/no-recursive-imports"] + assert "no-recursive-imports-lib" in page_ctx.frontend_imports + assert "no-recursive-imports-lib" in compile_ctx.all_imports + assert page_ctx.output_code is not None + + def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> None: page = UnevaluatedPage( component=lambda: Fragment.create(), From 292d095d1966f98250cec98f3e732bfbae74f0ee Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 00:30:18 +0500 Subject: [PATCH 13/31] Remove CompilerPlugin and PageDefinition protocols, use concrete types CompilerPlugin duplicated methods already on Plugin base class. PageDefinition was a structural typing protocol only satisfied by UnevaluatedPage. Replace both with their concrete counterparts. Give UnevaluatedPage fields defaults so it can be used directly in tests and benchmarks, eliminating FakePage and BenchmarkPage classes. --- .../src/reflex_base/plugins/__init__.py | 4 - .../src/reflex_base/plugins/compiler.py | 124 ++---------------- reflex/app.py | 12 +- reflex/compiler/plugins/__init__.py | 4 - reflex/compiler/plugins/builtin.py | 19 ++- reflex/plugins/__init__.py | 4 - tests/benchmarks/fixtures.py | 8 -- tests/benchmarks/test_compilation.py | 5 +- tests/benchmarks/test_evaluate.py | 5 +- tests/units/compiler/test_plugins.py | 50 +++---- 10 files changed, 51 insertions(+), 184 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index 5dab948b65a..dd542afcb4c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -7,10 +7,8 @@ BaseContext, CompileContext, CompilerHooks, - CompilerPlugin, ComponentAndChildren, PageContext, - PageDefinition, ) from .sitemap import SitemapPlugin from .tailwind_v3 import TailwindV3Plugin @@ -21,10 +19,8 @@ "CommonContext", "CompileContext", "CompilerHooks", - "CompilerPlugin", "ComponentAndChildren", "PageContext", - "PageDefinition", "Plugin", "PreCompileContext", "SitemapPlugin", diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index b96edf2d432..f8743799eb7 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Sequence from contextvars import ContextVar, Token from types import TracebackType -from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, cast +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast from typing_extensions import Self @@ -18,7 +18,7 @@ from .base import Plugin if TYPE_CHECKING: - from reflex.app import App, ComponentCallable + from reflex.app import App, ComponentCallable, UnevaluatedPage PageComponent: TypeAlias = Component | ComponentCallable else: @@ -31,20 +31,6 @@ ) -class PageDefinition(Protocol): - """Protocol for page-like objects compiled by :class:`CompileContext`.""" - - @property - def route(self) -> str: - """Return the route for this page definition.""" - ... - - @property - def component(self) -> PageComponent: - """Return the component or callable for this page definition.""" - ... - - ComponentAndChildren: TypeAlias = tuple[BaseComponent, tuple[BaseComponent, ...]] ComponentReplacement: TypeAlias = BaseComponent | ComponentAndChildren | None CompiledEnterHook: TypeAlias = Callable[ @@ -65,94 +51,11 @@ def component(self) -> PageComponent: ] -class CompilerPlugin(Protocol): - """Protocol for compiler plugins that participate in page compilation.""" - - def eval_page( - self, - page_fn: PageComponent, - /, - *, - page: PageDefinition, - **kwargs: Any, - ) -> PageContext | None: - """Evaluate a page-like object into a page context. - - Args: - page_fn: The page-like object to evaluate. - page: The page definition being compiled. - kwargs: Additional compiler-specific context. - - Returns: - A page context when the plugin can evaluate the page, otherwise ``None``. - """ - return None - - def compile_page( - self, - page_ctx: PageContext, - /, - **kwargs: Any, - ) -> None: - """Finalize a page context after its component tree has been traversed.""" - return - - def enter_component( - self, - comp: BaseComponent, - /, - *, - page_context: PageContext, - compile_context: CompileContext, - in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, - ) -> ComponentReplacement: - """Inspect or transform a component before visiting its descendants. - - Args: - comp: The component being compiled. - page_context: The active page compilation state. - compile_context: The active compile-run state. - in_prop_tree: Whether the component belongs to a prop subtree. - stateful_component: The active surrounding stateful component. - - Returns: - An optional replacement component and/or structural children. - """ - return None - - def leave_component( - self, - comp: BaseComponent, - children: tuple[BaseComponent, ...], - /, - *, - page_context: PageContext, - compile_context: CompileContext, - in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, - ) -> ComponentReplacement: - """Inspect or transform a component after visiting its descendants. - - Args: - comp: The component being compiled. - children: The compiled structural children for the component. - page_context: The active page compilation state. - compile_context: The active compile-run state. - in_prop_tree: Whether the component belongs to a prop subtree. - stateful_component: The active surrounding stateful component. - - Returns: - An optional replacement component and/or structural children. - """ - return None - - @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class CompilerHooks: """Dispatch compiler hooks across an ordered plugin chain.""" - plugins: tuple[CompilerPlugin, ...] = () + plugins: tuple[Plugin, ...] = () _eval_page_hooks: tuple[Callable[..., Any], ...] = dataclasses.field( init=False, repr=False, @@ -220,7 +123,7 @@ def __post_init__(self) -> None: @staticmethod def _get_hook_impl( - plugin: CompilerPlugin, + plugin: Plugin, hook_name: str, ) -> Callable[..., Any] | None: """Return the concrete hook implementation for a plugin, if any. @@ -237,12 +140,10 @@ def _get_hook_impl( if plugin_impl is None: return None - for base_cls in (CompilerPlugin, Plugin): - base_impl = inspect.getattr_static(base_cls, hook_name, None) - if plugin_impl is base_impl: - return None + if plugin_impl is inspect.getattr_static(Plugin, hook_name, None): + return None - return cast(Callable[..., Any], getattr(plugin, hook_name, None)) + return getattr(plugin, hook_name, None) def _resolve_hooks(self, hook_name: str) -> tuple[Callable[..., Any], ...]: """Resolve concrete hook implementations for the plugin chain. @@ -261,7 +162,7 @@ def _resolve_hooks(self, hook_name: str) -> tuple[Callable[..., Any], ...]: @staticmethod def _get_enter_hook_binder( - plugin: CompilerPlugin, + plugin: Plugin, hook_impl: Callable[..., Any], ) -> EnterHookBinder: """Return a binder that produces a compiled enter-component hook.""" @@ -295,7 +196,7 @@ def enter_component( @staticmethod def _get_leave_hook_binder( - plugin: CompilerPlugin, + plugin: Plugin, hook_impl: Callable[..., Any], ) -> LeaveHookBinder: """Return a binder that produces a compiled leave-component hook.""" @@ -334,7 +235,7 @@ def eval_page( page_fn: PageComponent, /, *, - page: PageDefinition, + page: UnevaluatedPage, **kwargs: Any, ) -> PageContext | None: """Return the first page context produced by the plugin chain.""" @@ -645,7 +546,7 @@ class CompileContext(BaseContext): """Mutable compilation state for an entire compile run.""" app: App | None = None - pages: Sequence[PageDefinition] + pages: Sequence[UnevaluatedPage] hooks: CompilerHooks = dataclasses.field(default_factory=CompilerHooks) compiled_pages: dict[str, PageContext] = dataclasses.field(default_factory=dict) all_imports: ParsedImportDict = dataclasses.field(default_factory=dict) @@ -801,8 +702,7 @@ def compile( "BaseContext", "CompileContext", "CompilerHooks", - "CompilerPlugin", "ComponentAndChildren", "PageContext", - "PageDefinition", + "Plugin", ] diff --git a/reflex/app.py b/reflex/app.py index 7b2a875ccef..f3972e662be 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -225,12 +225,12 @@ class UnevaluatedPage: component: Component | ComponentCallable route: str - title: Var | str | None - description: Var | str | None - image: str - on_load: EventType[()] | None - meta: Sequence[Mapping[str, Any] | Component] - context: Mapping[str, Any] + title: Var | str | None = None + description: Var | str | None = None + image: str = "" + on_load: EventType[()] | None = None + meta: Sequence[Mapping[str, Any] | Component] = () + context: Mapping[str, Any] = dataclasses.field(default_factory=dict) def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage: """Merge the other page into this one. diff --git a/reflex/compiler/plugins/__init__.py b/reflex/compiler/plugins/__init__.py index 393730e9cdb..2c641da4ed2 100644 --- a/reflex/compiler/plugins/__init__.py +++ b/reflex/compiler/plugins/__init__.py @@ -4,10 +4,8 @@ BaseContext, CompileContext, CompilerHooks, - CompilerPlugin, ComponentAndChildren, PageContext, - PageDefinition, ) from .builtin import ( @@ -22,11 +20,9 @@ "BaseContext", "CompileContext", "CompilerHooks", - "CompilerPlugin", "ComponentAndChildren", "DefaultCollectorPlugin", "DefaultPagePlugin", "PageContext", - "PageDefinition", "default_page_plugins", ] diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index e87f2935692..ff74536c5b4 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -4,7 +4,7 @@ import dataclasses from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from reflex_base.components.component import ( BaseComponent, @@ -13,13 +13,10 @@ StatefulComponent, ) from reflex_base.config import get_config -from reflex_base.plugins import ( - CompileContext, - CompilerPlugin, - PageContext, - PageDefinition, - Plugin, -) +from reflex_base.plugins import CompileContext, PageContext, Plugin + +if TYPE_CHECKING: + from reflex.app import UnevaluatedPage from reflex_base.utils.format import make_default_page_title from reflex_base.utils.imports import collapse_imports, merge_imports from reflex_base.vars import VarData @@ -37,7 +34,7 @@ def eval_page( page_fn: Any, /, *, - page: PageDefinition, + page: UnevaluatedPage, **kwargs: Any, ) -> PageContext: """Evaluate the page function and attach legacy page metadata. @@ -524,9 +521,9 @@ def default_page_plugins( style: ComponentStyle | None = None, theme: Component | None = None, stateful_custom_code_export: bool = False, -) -> tuple[CompilerPlugin, ...]: +) -> tuple[Plugin, ...]: """Return the default compiler plugin ordering for page compilation.""" - plugins: list[CompilerPlugin] = [DefaultPagePlugin()] + plugins: list[Plugin] = [DefaultPagePlugin()] if style is not None: plugins.append(ApplyStylePlugin(style=style, theme=theme)) plugins.append( diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py index 5b5f7bdc39f..114646fd0b1 100644 --- a/reflex/plugins/__init__.py +++ b/reflex/plugins/__init__.py @@ -5,10 +5,8 @@ CommonContext, CompileContext, CompilerHooks, - CompilerPlugin, ComponentAndChildren, PageContext, - PageDefinition, Plugin, PreCompileContext, SitemapPlugin, @@ -25,10 +23,8 @@ "CommonContext", "CompileContext", "CompilerHooks", - "CompilerPlugin", "ComponentAndChildren", "PageContext", - "PageDefinition", "Plugin", "PreCompileContext", "SitemapPlugin", diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index 1c8fd9c53c4..9436b0bfae7 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -225,14 +225,6 @@ class NestedElement(BaseModel): value: list[int] -@dataclass(frozen=True, slots=True) -class BenchmarkPage: - """Minimal page definition for compiler benchmark helpers.""" - - route: str - component: Callable[[], Component] - - @dataclass(frozen=True, slots=True) class ImportOnlyCollectorPlugin(DefaultCollectorPlugin): """Collect only imports — same scope as Component._get_all_imports. diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index 517c8168f0a..69ef9bb045f 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -2,11 +2,12 @@ from reflex_base.components.component import Component, StatefulComponent from reflex_base.plugins import CompileContext, CompilerHooks, PageContext +from reflex.app import UnevaluatedPage from reflex.compiler import compiler from reflex.compiler.compiler import _compile_page, _compile_stateful_components from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins -from .fixtures import BenchmarkPage, ImportOnlyCollectorPlugin +from .fixtures import ImportOnlyCollectorPlugin def import_templates(): @@ -66,7 +67,7 @@ def _compile_page_single_pass(component: Component) -> str: def _compile_page_full_context(unevaluated_page) -> str: - page = BenchmarkPage(route="/benchmark", component=unevaluated_page) + page = UnevaluatedPage(route="/benchmark", component=unevaluated_page) compile_ctx = CompileContext( pages=[page], hooks=CompilerHooks(plugins=default_page_plugins()), diff --git a/tests/benchmarks/test_evaluate.py b/tests/benchmarks/test_evaluate.py index 7c291730ded..b533b34c415 100644 --- a/tests/benchmarks/test_evaluate.py +++ b/tests/benchmarks/test_evaluate.py @@ -4,10 +4,9 @@ from reflex_base.components.component import Component from reflex_base.plugins import CompilerHooks +from reflex.app import UnevaluatedPage from reflex.compiler.plugins import DefaultPagePlugin -from .fixtures import BenchmarkPage - def test_evaluate_page( unevaluated_page: Callable[[], Component], benchmark: BenchmarkFixture @@ -20,5 +19,5 @@ def test_evaluate_page_single_pass( benchmark: BenchmarkFixture, ): hooks = CompilerHooks(plugins=(DefaultPagePlugin(),)) - page = BenchmarkPage(route="/benchmark", component=unevaluated_page) + page = UnevaluatedPage(route="/benchmark", component=unevaluated_page) benchmark(lambda: hooks.eval_page(page.component, page=page)) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index e798de04863..89a89ba0f31 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -1,7 +1,6 @@ # ruff: noqa: D101, D102 import dataclasses -from collections.abc import Callable from typing import Any import pytest @@ -18,10 +17,8 @@ BaseContext, CompileContext, CompilerHooks, - CompilerPlugin, ComponentAndChildren, PageContext, - PageDefinition, Plugin, ) from reflex_base.utils import format as format_utils @@ -41,18 +38,9 @@ @dataclasses.dataclass(slots=True) -class FakePage: - route: str - component: Callable[[], Component] - title: Var | str | None = None - description: Var | str | None = None - image: str = "" - meta: tuple[dict[str, Any], ...] = () - - class WrapperComponent(Component): - tag = "WrapperComponent" - library = "wrapper-lib" + tag: str | None = "WrapperComponent" + library: str | None = "wrapper-lib" @staticmethod def _get_app_wrap_components() -> dict[tuple[int, str], Component]: @@ -199,7 +187,7 @@ def collect_page_context( def test_eval_page_uses_first_non_none_result() -> None: calls: list[str] = [] - page = FakePage(route="/demo", component=lambda: Fragment.create()) + page = UnevaluatedPage(route="/demo", component=lambda: Fragment.create()) class NoMatchPlugin(StubCompilerPlugin): def eval_page( @@ -207,7 +195,7 @@ def eval_page( page_fn: Any, /, *, - page: PageDefinition, + page: UnevaluatedPage, **kwargs: Any, ) -> None: del page_fn, page, kwargs @@ -219,7 +207,7 @@ def eval_page( page_fn: Any, /, *, - page: PageDefinition, + page: UnevaluatedPage, **kwargs: Any, ) -> PageContext: del kwargs @@ -236,7 +224,7 @@ def eval_page( page_fn: Any, /, *, - page: PageDefinition, + page: UnevaluatedPage, **kwargs: Any, ) -> PageContext: del page_fn, page, kwargs @@ -395,12 +383,12 @@ def fail_enter_component( stateful_component: StatefulComponent | None = None, ) -> None: del self, comp, page_context, compile_context, in_prop_tree, stateful_component - msg = "Inherited CompilerPlugin.enter_component hook should be skipped." + msg = "Inherited Plugin.enter_component hook should be skipped." raise AssertionError(msg) - monkeypatch.setattr(CompilerPlugin, "enter_component", fail_enter_component) + monkeypatch.setattr(Plugin, "enter_component", fail_enter_component) - class ProtocolOnlyPlugin(CompilerPlugin): + class ProtocolOnlyPlugin(Plugin): pass class RealPlugin(StubCompilerPlugin): @@ -773,7 +761,7 @@ def test_default_page_plugins_are_minimal_and_ordered() -> None: def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: - page = FakePage(route="/demo", component=create_component_tree) + page = UnevaluatedPage(route="/demo", component=create_component_tree) compile_ctx = CompileContext( pages=[page], hooks=CompilerHooks(plugins=default_page_plugins(style=page_style())), @@ -828,7 +816,7 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: def test_compile_context_does_not_recurse_root_imports() -> None: - page = FakePage( + page = UnevaluatedPage( route="/no-recursive-imports", component=create_no_recursive_imports_component, ) @@ -880,8 +868,8 @@ def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> def test_compile_context_rejects_duplicate_routes() -> None: pages = [ - FakePage(route="/duplicate", component=lambda: Fragment.create()), - FakePage(route="/duplicate", component=lambda: Fragment.create()), + UnevaluatedPage(route="/duplicate", component=lambda: Fragment.create()), + UnevaluatedPage(route="/duplicate", component=lambda: Fragment.create()), ] compile_ctx = CompileContext( pages=pages, @@ -917,8 +905,8 @@ def test_compile_context_preserves_shared_stateful_component_imports_and_wraps() environment.REFLEX_ENV_MODE.set(constants.Env.PROD) try: pages = [ - FakePage(route="/a", component=create_shared_stateful_component), - FakePage(route="/b", component=create_shared_stateful_component), + UnevaluatedPage(route="/a", component=create_shared_stateful_component), + UnevaluatedPage(route="/b", component=create_shared_stateful_component), ] compile_ctx = CompileContext( pages=pages, @@ -944,8 +932,8 @@ def test_compile_context_resets_stateful_component_cache_between_runs() -> None: environment.REFLEX_ENV_MODE.set(constants.Env.PROD) prod_ctx = CompileContext( pages=[ - FakePage(route="/a", component=create_shared_stateful_component), - FakePage(route="/b", component=create_shared_stateful_component), + UnevaluatedPage(route="/a", component=create_shared_stateful_component), + UnevaluatedPage(route="/b", component=create_shared_stateful_component), ], hooks=CompilerHooks(plugins=default_page_plugins()), ) @@ -954,7 +942,9 @@ def test_compile_context_resets_stateful_component_cache_between_runs() -> None: environment.REFLEX_ENV_MODE.set(constants.Env.DEV) dev_ctx = CompileContext( - pages=[FakePage(route="/c", component=create_shared_stateful_component)], + pages=[ + UnevaluatedPage(route="/c", component=create_shared_stateful_component) + ], hooks=CompilerHooks(plugins=default_page_plugins()), ) with dev_ctx: From a0255f92d9c8beef4067a5d2a967bb2a40505992 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 00:36:49 +0500 Subject: [PATCH 14/31] pyi hashes --- pyi_hashes.json | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/pyi_hashes.json b/pyi_hashes.json index 07c6d6c0237..7a0db4b66a2 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,4 +1,123 @@ { + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "2797061144c4199f57848f6673a05a7f", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "db0de2879d57870831a030a69b5282b7", + "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", + "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "e7dfa98f5df5e30cb6d01d61b6974bef", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "0f98a7c1247e35059b76ae2985b7c81b", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "80a3090e5b7a46de6daa8e97e68e8638", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "f36f27e580041af842d348adbddcd600", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "39abed241f2def793dd0c59328bb0470", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "05d96de8a1d5f7be08de831b99663e67", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "b83e94900f988ef5d2fdf121b01be7fa", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "cfb0d5bcfe67f7c2b40868cdf3a5f7c1", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8a69093c8d40b10b1f0b1c4e851e9d53", + "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "29f5c106b98ddac94cf7c1244a02cfb1", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "9af2721b01868b24a48c7899ad6b1c69", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "20a3f4f500d44ac4365b6d831c6816ff", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "eb606cf8151e6769df7f2443ece739cd", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5e28d554d2b4d7fae1ba35809c24f4fc", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "28bd59898f0402b33c34e14f3eef1282", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "6f3cdef9956dbe5c917edeefdffd1b0e", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "28e901ee970bec806ee766d0d126d739", + "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", + "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "1a8824cdd243efc876157b97f9f1b714", + "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c74980207dc1a5cac14083f2edd31ba", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "da7ef00fd67699eeeb55e33279c2eb8d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "0ea0058ea7b6ae03138c7c85df963c32", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "97f7f6c66533bb3947a43ceefe160d49", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "7ea09671a42d75234a0464fc3601577c", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "869dca86b783149f9c59e1ae0d2900c1", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "c3a5a4f2d0594414a160fe59b13ccc26", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "b2acdc964feabe78154be141dc978555", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "e75fbe0454df06abf462ab579b698897", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "f88089a2f4270b981a28e385d07460b5", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "c5ac8ba14fdce557063a832a79f43f68", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "e10210239ce7dc18980e70eec19b9353", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2a93782c63e82a6939411273fe2486d9", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "f654cc9cb305712b485fcd676935c0c1", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "9c11bca2c4c5b722f55aba969f383e74", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "ad4b084d94e50311f761d69b3173e357", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "241b80584f3e029145e6e003d1c476f2", + "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "b2f485bfde4978047b7b944cf15d92cb", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "18ed34323f671fcf655639dc78d7c549", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "9c80e740d177b4a805dee3038d580941", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "b47313aefc9a740851ee332656446afd", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "d6a4f88f2988fa50fbed8a9026f5ef8b", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "00c0e0b6c8190f2db7fd847a25b5c03d", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "577ec9714a4d8bc9f7dd7eca22fe5252", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "bc69b9443d04ae7856c0a411a90755a9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "90a182a1444b73c006e52ea67c2b3db1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "2b8c68239c9e9646e71ef8e81d7b5f69", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0f981ee0589f5501ab3c57e0aec01316", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "d30f1bfb42198177ea08d7d358e99339", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "c3bb335b309177ff03d2cadcaf623744", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "6a01812d601e8bf3dcd30dcccc75cb79", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "9b853e851805addacc2fcd995119f857", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "67a71ec6ed4945a9ce270bd51d40b94e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "0c975a4812efc267c87119f10880e1a9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "6425aae44ffe78f48699910906d16285", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "d0029ee04a971d8a51be0c99e414a139", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "1ee25c7dd27fece9881800226e322d6b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "924addbc155a178709f5fd38af4eb547", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "e315e9779663f2f2fc9c2ca322a5645f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "ec6cb8830971b2a04bebe7459c059b15", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "28384945a53620ad6075797f8ada7354", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "6a3a37bdc9136f8c19fb3a7f55e76d64", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "05cfece835e2660bbc1b096529dfdec0", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "3033070773e8e32de283ad917367b386", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "798eadec25895a56e36d23203a4e0444", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "f6140dbf7ad4c25595c6983dcacc2a60", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "e16ca79a2ad4c2919f56efb54830c1ef", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "473703616ed18d983dda3600899710a5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "12eb86d24886764bf1a5815e87ea519c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "6319f89d046b0fce8e9efb51e50dda9f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "c6da1db236da70dc40815a404d2e29b3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "d2dabb895d7fc63a556d3c3220e38b4d", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "55b003f62cc3e5c85c90c82f8f595bc6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "c204f30612bfa35a62cb9f525a913f77", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "faeddfd0e3dc0e3bbcfdeaa6e42cb755", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "70f1d8fc55398d3cbb01f157c768419e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "a4c3052bc449924a630dad911f975e26", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ec4e4ed03bd892c6f7d50ae4b490adb9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "06549c800759ae541cc3c3a74240af59", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "dcb6a8ff4668082fc9406579098abf87", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "69e4ce4eeaa60ac90ef120331cb8601c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "dcbb1dc8e860379188924c15dd21605b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "28e6cd3869c9cbad886b69b339e3ecf6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "004cae8160c3a91ae6c12b54205f5112", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "9dbe595eddc2ec731beeb3a98743be36", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "1fb9d0ce37de9c64f681ad70375b9e42", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "a729044bfe2d82404de07c4570262b55", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "74b017b63728ce328e110bc64f20a205", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "3a595ec7faf95645ab52bdad1bf9dc4a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "f3e44e291f3d96d06850d262de5d43a8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "a0a59ca93ea1e3a0e5136b9692a68d18", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "6ab750e790f0687b735d7464fa289c1f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "3dd8bc1d7117b4e2b3b38438b4d6631a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "a71f56a8c51e9b00f953d87b16724bdb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "47a5f03dc4c85c473026069d23b6c531", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "ced137b2820a5e156cd1846ff113cfc9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "014444973b21272cf8c572b2913dfdf5", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "2c3c398ec0cc1476995f316cf8d0d271", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "9f8631e66d64f8bed90cbfd63615a97a", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "d0efeacb8b4162e9ace79f99c03e4368", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "9e99f951112c86ec7991bc80985a76b1", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "5730b770af97f8c67d6d2d50e84fe14d", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "4097350ca05011733ce998898c6aefe7", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "db5298160144f23ae7abcaac68e845c7", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "75150b01510bdacf2c97fca347c86c59", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "dc43e142b089b1158588e999505444f6", "reflex/__init__.pyi": "9321a11f6891d792fcd921cc1bdc64f4", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" From 4eaa2da546713913adbe970acd4f45ce88d07861 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 01:05:29 +0500 Subject: [PATCH 15/31] removed orphaned code used by legacy compiler --- .../src/reflex_base/environment.py | 104 +---------------- reflex/app.py | 3 +- reflex/compiler/compiler.py | 108 +++++++----------- reflex/compiler/plugins/builtin.py | 4 +- tests/units/compiler/test_plugins.py | 34 ++---- 5 files changed, 58 insertions(+), 195 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 31ebe795998..f2d4ce44cb9 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -2,14 +2,11 @@ from __future__ import annotations -import concurrent.futures import dataclasses import enum import importlib -import multiprocessing import os -import platform -from collections.abc import Callable, Sequence +from collections.abc import Sequence from functools import lru_cache from pathlib import Path from typing import ( @@ -529,97 +526,6 @@ class PerformanceMode(enum.Enum): OFF = "off" -class ExecutorType(enum.Enum): - """Executor for compiling the frontend.""" - - THREAD = "thread" - PROCESS = "process" - MAIN_THREAD = "main_thread" - - @classmethod - def get_executor_from_environment(cls): - """Get the executor based on the environment variables. - - Returns: - The executor. - """ - from reflex_base.utils import console - - executor_type = environment.REFLEX_COMPILE_EXECUTOR.get() - - reflex_compile_processes = environment.REFLEX_COMPILE_PROCESSES.get() - reflex_compile_threads = environment.REFLEX_COMPILE_THREADS.get() - # By default, use the main thread. Unless the user has specified a different executor. - # Using a process pool is much faster, but not supported on all platforms. It's gated behind a flag. - if executor_type is None: - if ( - platform.system() not in ("Linux", "Darwin") - and reflex_compile_processes is not None - ): - console.warn("Multiprocessing is only supported on Linux and MacOS.") - - if ( - platform.system() in ("Linux", "Darwin") - and reflex_compile_processes is not None - ): - if reflex_compile_processes == 0: - console.warn( - "Number of processes must be greater than 0. If you want to use the default number of processes, set REFLEX_COMPILE_EXECUTOR to 'process'. Defaulting to None." - ) - reflex_compile_processes = None - elif reflex_compile_processes < 0: - console.warn( - "Number of processes must be greater than 0. Defaulting to None." - ) - reflex_compile_processes = None - executor_type = ExecutorType.PROCESS - elif reflex_compile_threads is not None: - if reflex_compile_threads == 0: - console.warn( - "Number of threads must be greater than 0. If you want to use the default number of threads, set REFLEX_COMPILE_EXECUTOR to 'thread'. Defaulting to None." - ) - reflex_compile_threads = None - elif reflex_compile_threads < 0: - console.warn( - "Number of threads must be greater than 0. Defaulting to None." - ) - reflex_compile_threads = None - executor_type = ExecutorType.THREAD - else: - executor_type = ExecutorType.MAIN_THREAD - - match executor_type: - case ExecutorType.PROCESS: - executor = concurrent.futures.ProcessPoolExecutor( - max_workers=reflex_compile_processes, - mp_context=multiprocessing.get_context("fork"), - ) - case ExecutorType.THREAD: - executor = concurrent.futures.ThreadPoolExecutor( - max_workers=reflex_compile_threads - ) - case ExecutorType.MAIN_THREAD: - FUTURE_RESULT_TYPE = TypeVar("FUTURE_RESULT_TYPE") - - class MainThreadExecutor: - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - def submit( - self, fn: Callable[..., FUTURE_RESULT_TYPE], *args, **kwargs - ) -> concurrent.futures.Future[FUTURE_RESULT_TYPE]: - future_job = concurrent.futures.Future() - future_job.set_result(fn(*args, **kwargs)) - return future_job - - executor = MainThreadExecutor() - - return executor - - class EnvironmentVariables: """Environment variables class to instantiate environment variables.""" @@ -660,14 +566,6 @@ class EnvironmentVariables: Path(constants.Dirs.UPLOADED_FILES) ) - REFLEX_COMPILE_EXECUTOR: EnvVar[ExecutorType | None] = env_var(None) - - # Whether to use separate processes to compile the frontend and how many. If not set, defaults to thread executor. - REFLEX_COMPILE_PROCESSES: EnvVar[int | None] = env_var(None) - - # Whether to use separate threads to compile the frontend and how many. Defaults to `min(32, os.cpu_count() + 4)`. - REFLEX_COMPILE_THREADS: EnvVar[int | None] = env_var(None) - # The directory to store reflex dependencies. REFLEX_DIR: EnvVar[Path] = env_var(constants.Reflex.DIR) diff --git a/reflex/app.py b/reflex/app.py index f3972e662be..a2fa9b3e9ee 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -813,7 +813,8 @@ def _compile_page(self, route: str, save_page: bool = True): """ n_states_before = len(all_base_state_classes) component = compiler.compile_unevaluated_page( - route, self._unevaluated_pages[route], self.style, self.theme + self._unevaluated_pages[route], + style=self.style, ) # Indicate that evaluating this page creates one or more state classes. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 0b288a8ad39..fba3e6f19a1 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule from pathlib import Path @@ -870,76 +869,59 @@ def into_component(component: Component | ComponentCallable) -> Component: def compile_unevaluated_page( - route: str, page: UnevaluatedPage, + *, style: ComponentStyle | None = None, - theme: Component | None = None, ) -> Component: - """Compiles an uncompiled page into a component and adds meta information. + """Compile an unevaluated page through the compiler plugin pipeline. + + This evaluates the page and applies the page compiler hooks before + returning the compiled root component. Args: - route: The route of the page. - page: The uncompiled page object. - style: The style of the page. - theme: The theme of the page. + page: The unevaluated page definition. + style: The app-level style map to apply. Returns: - The compiled component and whether state should be enabled. - - Raises: - Exception: If an error occurs while evaluating the page. + The compiled root component. """ - try: - # Generate the component if it is a callable. - component = into_component(page.component) - - component._add_style_recursive(style or {}, theme) - - from reflex_base.utils.format import make_default_page_title - - component = Fragment.create(component) - - meta_args = { - "title": ( - page.title - if page.title is not None - else make_default_page_title(get_config().app_name, route) - ), - "image": page.image, - "meta": page.meta, - } - - if page.description is not None: - meta_args["description"] = page.description - - # Add meta information to the component. - utils.add_meta( - component, - **meta_args, + hooks = CompilerHooks(plugins=default_page_plugins(style=style)) + compile_ctx = CompileContext(pages=[page], hooks=hooks) + + with compile_ctx: + page_ctx = hooks.eval_page( + page.component, + page=page, + compile_context=compile_ctx, ) + if page_ctx is None: + page_name = getattr(page.component, "__name__", repr(page.component)) + msg = ( + f"No compiler plugin was able to evaluate page {page.route!r} " + f"({page_name})." + ) + raise RuntimeError(msg) - except Exception as e: - if sys.version_info >= (3, 11): - e.add_note(f"Happened while evaluating page {route!r}") - raise - else: - return component - + with page_ctx: + page_ctx.root_component = hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page( + page_ctx, + page=page, + compile_context=compile_ctx, + ) -def _compile_page_from_app( - app: App, - route: str, - *, - save_page: bool = True, -) -> None: - """Evaluate a page from an app and optionally save it. + if not isinstance(page_ctx.root_component, Component): + msg = ( + f"Compiled page {page.route!r} root must be a Component before it can " + "be returned." + ) + raise TypeError(msg) - Args: - app: The app being compiled. - route: The route to evaluate. - save_page: Whether to store the evaluated page on the app. - """ - app._compile_page(route, save_page=save_page) + return page_ctx.root_component def _resolve_app_wrap_components( @@ -1010,7 +992,7 @@ def compile_app( stateful_pages = json.load(file) for route in stateful_pages: console.debug(f"BE Evaluating stateful page: {route}") - _compile_page_from_app(app, route, save_page=False) + app._compile_page(route, save_page=False) app._add_optional_endpoints() return @@ -1024,7 +1006,7 @@ def compile_app( with console.timing("Evaluate Pages (Backend)"): for route in app._unevaluated_pages: console.debug(f"Evaluating page: {route}") - _compile_page_from_app(app, route, save_page=False) + app._compile_page(route, save_page=False) app._write_stateful_pages_marker() app._add_optional_endpoints() @@ -1047,9 +1029,7 @@ def compile_app( compile_ctx = CompileContext( app=app, pages=list(app._unevaluated_pages.values()), - hooks=CompilerHooks( - plugins=default_page_plugins(style=app.style, theme=app.theme) - ), + hooks=CompilerHooks(plugins=default_page_plugins(style=app.style)), ) with console.timing("Compile pages"), compile_ctx: diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index ff74536c5b4..62184e8817f 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -81,7 +81,6 @@ class ApplyStylePlugin(Plugin): """Apply app-level styles in the descending phase of the walk.""" style: ComponentStyle | None = None - theme: Component | None = None @staticmethod def _apply_style(comp: Component, style: ComponentStyle) -> None: @@ -519,13 +518,12 @@ def _collect_wrapper_subtree_into( def default_page_plugins( *, style: ComponentStyle | None = None, - theme: Component | None = None, stateful_custom_code_export: bool = False, ) -> tuple[Plugin, ...]: """Return the default compiler plugin ordering for page compilation.""" plugins: list[Plugin] = [DefaultPagePlugin()] if style is not None: - plugins.append(ApplyStylePlugin(style=style, theme=theme)) + plugins.append(ApplyStylePlugin(style=style)) plugins.append( DefaultCollectorPlugin(stateful_custom_code_export=stateful_custom_code_export) ) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 89a89ba0f31..7a319bd3743 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -760,7 +760,7 @@ def test_default_page_plugins_are_minimal_and_ordered() -> None: assert isinstance(plugins[2], DefaultCollectorPlugin) -def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: +def test_compile_context_compiles_pages_and_matches_direct_page_compile() -> None: page = UnevaluatedPage(route="/demo", component=create_component_tree) compile_ctx = CompileContext( pages=[page], @@ -796,22 +796,11 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: == page_ctx.root_component._get_all_app_wrap_components().keys() ) - legacy_component = compiler.compile_unevaluated_page( - page.route, - UnevaluatedPage( - component=page.component, - route=page.route, - title=page.title, - description=page.description, - image=page.image, - on_load=None, - meta=page.meta, - context={}, - ), - page_style(), - None, + expected_component = compiler.compile_unevaluated_page( + page, + style=page_style(), ) - expected_output = compiler.compile_page(page.route, legacy_component)[1] + expected_output = compiler.compile_page(page.route, expected_component)[1] assert page_ctx.output_code == expected_output @@ -834,7 +823,9 @@ def test_compile_context_does_not_recurse_root_imports() -> None: assert page_ctx.output_code is not None -def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> None: +def test_default_page_plugin_handles_var_backed_title_like_direct_page_compile() -> ( + None +): page = UnevaluatedPage( component=lambda: Fragment.create(), route="/var-title", @@ -857,13 +848,8 @@ def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> assert page_ctx is not None - legacy_component = compiler.compile_unevaluated_page( - page.route, - page, - None, - None, - ) - assert page_ctx.root_component.render() == legacy_component.render() + expected_component = compiler.compile_unevaluated_page(page) + assert page_ctx.root_component.render() == expected_component.render() def test_compile_context_rejects_duplicate_routes() -> None: From d7f43b81ce0b2bcdcbf38171c4b90640caf794ee Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 23:32:21 +0500 Subject: [PATCH 16/31] fixed borken merge --- .../src/reflex_base/plugins/compiler.py | 22 ------------------- reflex/app.py | 15 ++----------- reflex/compiler/compiler.py | 1 - 3 files changed, 2 insertions(+), 36 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index f8743799eb7..8431c274f3c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -562,7 +562,6 @@ def compile( *, evaluate_progress: Callable[[], None] | None = None, render_progress: Callable[[], None] | None = None, - apply_overlay: bool = False, **kwargs: Any, ) -> dict[str, PageContext]: """Compile all configured pages through the plugin pipeline. @@ -570,7 +569,6 @@ def compile( Args: evaluate_progress: Callback invoked after each page evaluation. render_progress: Callback invoked after each page render. - apply_overlay: Whether to apply the app overlay during evaluation. kwargs: Additional compiler-specific context. Returns: @@ -589,14 +587,6 @@ def compile( self.stateful_components_code = "" stateful_component_cache: dict[str, StatefulComponent] = {} - overlay_component: Component | None = None - if ( - apply_overlay - and self.app is not None - and self.app.overlay_component is not None - ): - overlay_component = self.app._generate_component(self.app.overlay_component) - for page in self.pages: page_fn = page.component n_states_before = len(all_base_state_classes) @@ -620,18 +610,6 @@ def compile( if len(all_base_state_classes) > n_states_before: self.stateful_routes[page.route] = None - if overlay_component is not None and self.app is not None: - if not isinstance(page_ctx.root_component, Component): - msg = ( - f"Compiled page {page_ctx.route!r} root must be a Component " - "to apply the overlay." - ) - raise TypeError(msg) - page_ctx.root_component = self.app._add_overlay_to_component( - page_ctx.root_component, - overlay_component, - ) - if isinstance(page_ctx.root_component, StatefulComponent): self.app_wrap_components.update( page_ctx.root_component.component._get_all_app_wrap_components() diff --git a/reflex/app.py b/reflex/app.py index 28f0be12778..f7b757abf41 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -183,10 +183,10 @@ def extra_overlay_function() -> Component | None: def default_overlay_component() -> Component: - """Default overlay_component attribute for App. + """Default overlay component included in the app wraps. Returns: - The default overlay_component, which is a connection_modal. + The default overlay component, which is a connection banner/toaster set. """ from reflex_base.components.component import memo @@ -283,7 +283,6 @@ class App(MiddlewareMixin, LifespanMixin): style: The [global style](https://reflex.dev/docs/styling/overview/#global-styles}) for the app. stylesheets: A list of URLs to [stylesheets](https://reflex.dev/docs/styling/custom-stylesheets/) to include in the app. reset_style: Whether to include CSS reset for margin and padding. Defaults to True. - overlay_component: A component that is present on every page. Defaults to the Connection Error banner. app_wraps: App wraps to be applied to the whole app. Expected to be a dictionary of (order, name) to a function that takes whether the state is enabled and optionally returns a component. extra_app_wraps: Extra app wraps to be applied to the whole app. head_components: Components to add to the head of every page. @@ -1058,16 +1057,6 @@ def _should_compile(self) -> bool: # By default, compile the app. return True - def _add_overlay_to_component( - self, component: Component, overlay_component: Component - ) -> Component: - children = component.children - - if children[0] == overlay_component: - return component - - return Fragment.create(overlay_component, *children) - def _setup_sticky_badge(self): """Add the sticky badge to the app.""" from reflex_base.components.component import memo diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index fba3e6f19a1..07d18fdcf3b 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1034,7 +1034,6 @@ def compile_app( with console.timing("Compile pages"), compile_ctx: compile_ctx.compile( - apply_overlay=True, evaluate_progress=lambda: progress.advance(task), render_progress=lambda: progress.advance(task), ) From c50b245351660eabc347ab6b385371714e99e145 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Apr 2026 11:44:39 -0700 Subject: [PATCH 17/31] fix reflex dep as git url exclusion for reflex-web CI --- .github/workflows/integration_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 815427aeaf6..56593ec6166 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -178,7 +178,7 @@ jobs: run: | # Install git+https deps from pyproject.toml before pip compile resolves them. # Exclude reflex itself — the PR version is already installed. - grep -oP 'git\+https://[^"'"'"']+' pyproject.toml | grep -v 'reflex-dev/reflex\.git' | sort -u > git-requirements.txt || true + grep -oP 'git\+https://[^"'"'"']+' pyproject.toml | grep -v 'reflex-dev/reflex@' | sort -u > git-requirements.txt || true if [ -s git-requirements.txt ]; then echo "Installing git dependencies:" cat git-requirements.txt From 0f91a264a835cb805bec79e5be68b7b354994b6d Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 23:49:31 +0500 Subject: [PATCH 18/31] reflex web fix --- .github/workflows/integration_tests.yml | 13 ++- pyi_hashes.json | 119 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 815427aeaf6..90064d4178f 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -177,8 +177,10 @@ jobs: working-directory: ./reflex-web run: | # Install git+https deps from pyproject.toml before pip compile resolves them. - # Exclude reflex itself — the PR version is already installed. - grep -oP 'git\+https://[^"'"'"']+' pyproject.toml | grep -v 'reflex-dev/reflex\.git' | sort -u > git-requirements.txt || true + # Exclude reflex repo URLs — the PR checkout is already installed in the active venv. + grep -oP 'git\+https://[^"'"'"']+' pyproject.toml \ + | grep -vE 'github\.com/reflex-dev/reflex(\.git)?([@#]|$)' \ + | sort -u > git-requirements.txt || true if [ -s git-requirements.txt ]; then echo "Installing git dependencies:" cat git-requirements.txt @@ -194,6 +196,13 @@ jobs: if [ -s requirements.txt ]; then sfw uv pip install -r requirements.txt fi + - name: Verify installed reflex version matches this checkout + run: | + expected_sha="$(git rev-parse --short=8 HEAD)" + installed_version="$(uv run --active --no-sync python -c 'import importlib.metadata as metadata; print(metadata.version("reflex"))')" + echo "Expected checkout SHA: $expected_sha" + echo "Installed reflex version: $installed_version" + [[ "$installed_version" == *"+$expected_sha" ]] - name: Init Website for reflex-web working-directory: ./reflex-web run: uv run --active --no-sync reflex init diff --git a/pyi_hashes.json b/pyi_hashes.json index 7b2a6e0adec..f7900836abd 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,4 +1,123 @@ { + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "2797061144c4199f57848f6673a05a7f", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "db0de2879d57870831a030a69b5282b7", + "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", + "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "e7dfa98f5df5e30cb6d01d61b6974bef", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "0f98a7c1247e35059b76ae2985b7c81b", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "80a3090e5b7a46de6daa8e97e68e8638", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "f36f27e580041af842d348adbddcd600", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "39abed241f2def793dd0c59328bb0470", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "05d96de8a1d5f7be08de831b99663e67", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "b83e94900f988ef5d2fdf121b01be7fa", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "cfb0d5bcfe67f7c2b40868cdf3a5f7c1", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8a69093c8d40b10b1f0b1c4e851e9d53", + "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "29f5c106b98ddac94cf7c1244a02cfb1", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "9af2721b01868b24a48c7899ad6b1c69", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "20a3f4f500d44ac4365b6d831c6816ff", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "eb606cf8151e6769df7f2443ece739cd", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5e28d554d2b4d7fae1ba35809c24f4fc", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "28bd59898f0402b33c34e14f3eef1282", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "6f3cdef9956dbe5c917edeefdffd1b0e", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "28e901ee970bec806ee766d0d126d739", + "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", + "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "1a8824cdd243efc876157b97f9f1b714", + "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c74980207dc1a5cac14083f2edd31ba", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "da7ef00fd67699eeeb55e33279c2eb8d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "0ea0058ea7b6ae03138c7c85df963c32", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "97f7f6c66533bb3947a43ceefe160d49", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "7ea09671a42d75234a0464fc3601577c", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "869dca86b783149f9c59e1ae0d2900c1", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "c3a5a4f2d0594414a160fe59b13ccc26", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "b2acdc964feabe78154be141dc978555", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "e75fbe0454df06abf462ab579b698897", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "f88089a2f4270b981a28e385d07460b5", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "c5ac8ba14fdce557063a832a79f43f68", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "e10210239ce7dc18980e70eec19b9353", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2a93782c63e82a6939411273fe2486d9", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "f654cc9cb305712b485fcd676935c0c1", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "2d6efa2d5f2586a7036d606a24fb425d", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "ad4b084d94e50311f761d69b3173e357", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "241b80584f3e029145e6e003d1c476f2", + "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "b2f485bfde4978047b7b944cf15d92cb", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "18ed34323f671fcf655639dc78d7c549", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "9c80e740d177b4a805dee3038d580941", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "b47313aefc9a740851ee332656446afd", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "d6a4f88f2988fa50fbed8a9026f5ef8b", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "00c0e0b6c8190f2db7fd847a25b5c03d", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "577ec9714a4d8bc9f7dd7eca22fe5252", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "bc69b9443d04ae7856c0a411a90755a9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "90a182a1444b73c006e52ea67c2b3db1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "2b8c68239c9e9646e71ef8e81d7b5f69", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0f981ee0589f5501ab3c57e0aec01316", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "d30f1bfb42198177ea08d7d358e99339", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "c3bb335b309177ff03d2cadcaf623744", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "6a01812d601e8bf3dcd30dcccc75cb79", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "9b853e851805addacc2fcd995119f857", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "67a71ec6ed4945a9ce270bd51d40b94e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "0c975a4812efc267c87119f10880e1a9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "6425aae44ffe78f48699910906d16285", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "d0029ee04a971d8a51be0c99e414a139", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "1ee25c7dd27fece9881800226e322d6b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "924addbc155a178709f5fd38af4eb547", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "e315e9779663f2f2fc9c2ca322a5645f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "ec6cb8830971b2a04bebe7459c059b15", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "28384945a53620ad6075797f8ada7354", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "6a3a37bdc9136f8c19fb3a7f55e76d64", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "05cfece835e2660bbc1b096529dfdec0", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "3033070773e8e32de283ad917367b386", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "798eadec25895a56e36d23203a4e0444", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "f6140dbf7ad4c25595c6983dcacc2a60", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "e16ca79a2ad4c2919f56efb54830c1ef", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "473703616ed18d983dda3600899710a5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "12eb86d24886764bf1a5815e87ea519c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "6319f89d046b0fce8e9efb51e50dda9f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "c6da1db236da70dc40815a404d2e29b3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "d2dabb895d7fc63a556d3c3220e38b4d", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "55b003f62cc3e5c85c90c82f8f595bc6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "c204f30612bfa35a62cb9f525a913f77", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "faeddfd0e3dc0e3bbcfdeaa6e42cb755", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "70f1d8fc55398d3cbb01f157c768419e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "a4c3052bc449924a630dad911f975e26", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ec4e4ed03bd892c6f7d50ae4b490adb9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "06549c800759ae541cc3c3a74240af59", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "dcb6a8ff4668082fc9406579098abf87", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "69e4ce4eeaa60ac90ef120331cb8601c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "dcbb1dc8e860379188924c15dd21605b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "28e6cd3869c9cbad886b69b339e3ecf6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "004cae8160c3a91ae6c12b54205f5112", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "9dbe595eddc2ec731beeb3a98743be36", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "1fb9d0ce37de9c64f681ad70375b9e42", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "a729044bfe2d82404de07c4570262b55", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "74b017b63728ce328e110bc64f20a205", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "3a595ec7faf95645ab52bdad1bf9dc4a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "f3e44e291f3d96d06850d262de5d43a8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "a0a59ca93ea1e3a0e5136b9692a68d18", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "6ab750e790f0687b735d7464fa289c1f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "3dd8bc1d7117b4e2b3b38438b4d6631a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "a71f56a8c51e9b00f953d87b16724bdb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "47a5f03dc4c85c473026069d23b6c531", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "ced137b2820a5e156cd1846ff113cfc9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "014444973b21272cf8c572b2913dfdf5", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "2c3c398ec0cc1476995f316cf8d0d271", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "9f8631e66d64f8bed90cbfd63615a97a", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "d0efeacb8b4162e9ace79f99c03e4368", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "9e99f951112c86ec7991bc80985a76b1", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "5730b770af97f8c67d6d2d50e84fe14d", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "4097350ca05011733ce998898c6aefe7", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "db5298160144f23ae7abcaac68e845c7", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "75150b01510bdacf2c97fca347c86c59", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "dc43e142b089b1158588e999505444f6", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" From d391cd0d47c1dd05a0af3c4101e60d7cc24c8dfd Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Apr 2026 11:55:35 -0700 Subject: [PATCH 19/31] integration-tests.yml: do not reinstall reflex from git The reflex version in reflex-web's pyproject.toml is of no consequence to us, we want to test the reflex version in the current PR. --- .github/workflows/integration_tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 90064d4178f..7005e276799 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -176,11 +176,11 @@ jobs: - name: Pre-install reflex-web git dependencies (outside sfw) working-directory: ./reflex-web run: | + # Replace reflex-dev/reflex git deps with plain package names (PR version is pre-installed) + sed -i -E 's|"([a-zA-Z0-9_-]+)\s*@\s*git\+https://github\.com/reflex-dev/reflex@[^"]*"|"\1"|g' pyproject.toml # Install git+https deps from pyproject.toml before pip compile resolves them. - # Exclude reflex repo URLs — the PR checkout is already installed in the active venv. - grep -oP 'git\+https://[^"'"'"']+' pyproject.toml \ - | grep -vE 'github\.com/reflex-dev/reflex(\.git)?([@#]|$)' \ - | sort -u > git-requirements.txt || true + # Exclude reflex itself — the PR version is already installed. + grep -oP 'git\+https://[^"'"'"']+' pyproject.toml | sort -u > git-requirements.txt || true if [ -s git-requirements.txt ]; then echo "Installing git dependencies:" cat git-requirements.txt From ee4dcb468197a872e35546dfec736913a244580d Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 01:51:01 +0500 Subject: [PATCH 20/31] Replace StatefulComponent with MemoizeStatefulPlugin compiler plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the StatefulComponent class (~470 lines) and its two-pass compile model (evaluate → StatefulComponent.compile_from → render). Replace it with MemoizeStatefulPlugin, a single-pass compiler plugin that auto-memoizes stateful subtrees using the experimental memo infrastructure. Key changes: - Delete StatefulComponent from component.py. All memoization logic now lives in reflex/compiler/plugins/memoize.py (MemoizeStatefulPlugin) and reflex_base/components/memoize_helpers.py (event trigger helpers). - Remove the shared stateful_components module. Memoized wrappers are now compiled as experimental memo components into $/utils/components, tracked via CompileContext.memoize_wrappers and auto_memo_components. - Remove CompilerPlugin protocol — Plugin base class already provides the same eval_page/compile_page/enter_component/leave_component interface. - Add PageDefinition protocol so CompileContext.pages is decoupled from UnevaluatedPage, allowing test fixtures to provide minimal page-like objects. - Add three specialized tree-walk methods to CompilerHooks (_compile_component_without_replacements, _single_enter_fast_path, _with_replacements) to avoid replacement-dispatch overhead when no plugin can replace components. - Simplify compile_unevaluated_page to inline page evaluation directly (route, style, theme as positional args) instead of going through the full plugin pipeline. - Remove redundant _get_all_imports() and _get_all_app_wrap_components() calls from the evaluate loop — the tree walk via DefaultCollectorPlugin already collects these into PageContext. - Remove dead code: _compile_page_from_app wrapper, get_stateful_components_path, compile_stateful_components, _compile_stateful_components, _get_shared_components_recursive. - Clean up Plugin.enter_component / leave_component signatures by removing the stateful_component parameter from all hooks. - Remove unused _enter_component_hooks/_leave_component_hooks fields from CompilerHooks (only binders are used at runtime). - Remove dead apply_overlay parameter from CompileContext.compile(). --- .../src/reflex_base/compiler/templates.py | 27 +- .../src/reflex_base/components/component.py | 477 +----------------- .../src/reflex_base/components/dynamic.py | 5 +- .../reflex_base/components/memoize_helpers.py | 175 +++++++ .../src/reflex_base/plugins/__init__.py | 2 + .../src/reflex_base/plugins/base.py | 15 +- .../src/reflex_base/plugins/compiler.py | 394 ++++++++++----- .../reflex-base/src/reflex_base/registry.py | 5 - .../src/reflex_components_core/core/upload.py | 4 +- .../core/window_events.py | 6 +- pyi_hashes.json | 6 +- reflex/app.py | 4 +- reflex/compiler/compiler.py | 228 ++------- reflex/compiler/plugins/__init__.py | 2 + reflex/compiler/plugins/builtin.py | 154 +----- reflex/compiler/plugins/memoize.py | 289 +++++++++++ reflex/compiler/utils.py | 13 - reflex/experimental/memo.py | 45 ++ tests/benchmarks/fixtures.py | 12 +- tests/benchmarks/test_compilation.py | 22 +- tests/integration/test_auto_memo.py | 73 +++ tests/units/compiler/test_memoize_plugin.py | 216 ++++++++ tests/units/compiler/test_plugins.py | 330 ++++++------ tests/units/components/test_component.py | 49 -- 24 files changed, 1369 insertions(+), 1184 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/components/memoize_helpers.py create mode 100644 reflex/compiler/plugins/memoize.py create mode 100644 tests/integration/test_auto_memo.py create mode 100644 tests/units/compiler/test_memoize_plugin.py diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index be3e3f6eee4..9091b8edfc5 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from reflex.compiler.utils import _ImportDict - from reflex_base.components.component import Component, StatefulComponent + from reflex_base.components.component import Component def _sort_hooks( @@ -417,7 +417,7 @@ def context_template( }}""" -def component_template(component: Component | StatefulComponent): +def component_template(component: Component): """Template to render a component tag. Args: @@ -618,24 +618,23 @@ def vite_config_template( }}));""" -def stateful_component_template( - tag_name: str, memo_trigger_hooks: list[str], component: Component, export: bool -): - """Template for stateful component. +def dynamic_component_template( + tag_name: str, component: Component, export: bool +) -> str: + """Template for a dynamic SSR component function declaration. Args: tag_name: The tag name for the component. - memo_trigger_hooks: The memo trigger hooks for the component. component: The component to render. export: Whether to export the component. Returns: - Rendered stateful component code as string. + Rendered dynamic component code as string. """ all_hooks = component._get_all_hooks() return f""" {"export " if export else ""}function {tag_name} () {{ - {_render_hooks(all_hooks, memo_trigger_hooks)} + {_render_hooks(all_hooks)} return ( {_RenderUtils.render(component.render())} ) @@ -643,15 +642,17 @@ def stateful_component_template( """ -def stateful_components_template(imports: list[_ImportDict], memoized_code: str) -> str: - """Template for stateful components. +def dynamic_components_module_template( + imports: list[_ImportDict], memoized_code: str +) -> str: + """Template for a dynamic-SSR components module. Args: imports: List of import statements. - memoized_code: Memoized code for stateful components. + memoized_code: Code for the module body. Returns: - Rendered stateful components code as string. + Rendered module code as string. """ imports_str = "\n".join([_RenderUtils.get_import(imp) for imp in imports]) return f"{imports_str}\n{memoized_code}" diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 30edad9dbf4..6fc4effeccd 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import copy import dataclasses import enum import functools @@ -22,7 +21,6 @@ from reflex_base import constants from reflex_base.breakpoints import Breakpoints -from reflex_base.compiler.templates import stateful_component_template from reflex_base.components.dynamic import load_dynamic_serializer from reflex_base.components.field import BaseField, FieldBasedMeta from reflex_base.components.tags import Tag @@ -33,7 +31,6 @@ Imports, MemoizationDisposition, MemoizationMode, - PageNames, ) from reflex_base.constants.compiler import SpecialAttributes from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER @@ -1298,7 +1295,7 @@ def _add_style_recursive( # Recursively add style to the children. for child in self.children: - # Skip BaseComponent and StatefulComponent children. + # Skip non-Component children. if not isinstance(child, Component): continue child._add_style_recursive(style, theme) @@ -1460,8 +1457,8 @@ def _get_vars( Yields: Each var referenced by the component (props, styles, event handlers). """ - # Default-args fast path is cached per instance. Invalidated by - # StatefulComponent.create when _fix_event_triggers mutates event_triggers. + # Default-args fast path is cached per instance. Invalidated by the + # auto-memoize plugin when fix_event_triggers_for_memo mutates event_triggers. if not include_children and ignore_ids is None: cached = self.__dict__.get("_vars_cache") if cached is not None: @@ -2024,7 +2021,7 @@ def _get_all_app_wrap_components( # Add the app wrap components for the children. for child in self.children: child_id = id(child) - # Skip BaseComponent and StatefulComponent children. + # Skip non-Component children. if not isinstance(child, Component) or child_id in ignore_ids: continue ignore_ids.add(child_id) @@ -2401,472 +2398,6 @@ def _get_dynamic_imports(self) -> str: ) -class StatefulComponent(BaseComponent): - """A component that depends on state and is rendered outside of the page component. - - If a StatefulComponent is used in multiple pages, it will be rendered to a common file and - imported into each page that uses it. - - A stateful component has a tag name that includes a hash of the code that it renders - to. This tag name refers to the specific component with the specific props that it - was created with. - """ - - # Reference to the original component that was memoized into this component. - component: Component = field( - default_factory=Component, is_javascript_property=False - ) - - references: int = field( - doc="How many times this component is referenced in the app.", - default=0, - is_javascript_property=False, - ) - - rendered_as_shared: bool = field( - doc="Whether the component has already been rendered to a shared file.", - default=False, - is_javascript_property=False, - ) - - memo_trigger_hooks: list[str] = field( - default_factory=list, is_javascript_property=False - ) - - @classmethod - def create( - cls, - component: Component, - *, - stateful_component_cache: dict[str, StatefulComponent] | None = None, - ) -> StatefulComponent | None: - """Create a stateful component from a component. - - Args: - component: The component to memoize. - stateful_component_cache: Compile-scoped cache of memoized components. - - Returns: - The stateful component or None if the component should not be memoized. - """ - from reflex_components_core.core.foreach import Foreach - - if component._memoization_mode.disposition == MemoizationDisposition.NEVER: - # Never memoize this component. - return None - - if component.tag is None: - # Only memoize components with a tag. - return None - - # If _var_data is found in this component, it is a candidate for auto-memoization. - should_memoize = False - - # If the component requests to be memoized, then ignore other checks. - if component._memoization_mode.disposition == MemoizationDisposition.ALWAYS: - should_memoize = True - - if not should_memoize: - # Determine if any Vars have associated data. - for prop_var in component._get_vars(include_children=True): - if prop_var._get_all_var_data(): - should_memoize = True - break - - if not should_memoize: - # Check for special-cases in child components. - for child in component.children: - # Skip BaseComponent and StatefulComponent children. - if not isinstance(child, Component): - continue - # Always consider Foreach something that must be memoized by the parent. - if isinstance(child, Foreach): - should_memoize = True - break - child = cls._child_var(child) - if isinstance(child, Var) and child._get_all_var_data(): - should_memoize = True - break - - if should_memoize or component.event_triggers: - # Render the component to determine tag+hash based on component code. - tag_name = cls._get_tag_name(component) - if tag_name is None: - return None - - cache = ( - stateful_component_cache if stateful_component_cache is not None else {} - ) - # Look up the tag in the compile-scoped cache. - stateful_component = cache.get(tag_name) - if stateful_component is None: - memo_trigger_hooks = cls._fix_event_triggers(component) - if memo_trigger_hooks: - # event_triggers were mutated via shared dict; invalidate - # every derived cache on the top-level component so - # _render_stateful_code sees the memoized triggers. - # Children are unaffected and keep their cached results. - for attr in ( - "_cached_render_result", - "_vars_cache", - "_imports_cache", - "_hooks_internal_cache", - ): - with contextlib.suppress(AttributeError): - delattr(component, attr) - stateful_component = cls( - children=component.children, - component=component, - tag=tag_name, - memo_trigger_hooks=memo_trigger_hooks, - ) - cache[tag_name] = stateful_component - # Bump the reference count -- multiple pages referencing the same component - # will result in writing it to a common file. - stateful_component.references += 1 - return stateful_component - - # Return None to indicate this component should not be memoized. - return None - - @staticmethod - def _child_var(child: Component) -> Var | Component: - """Get the Var from a child component. - - This method is used for special cases when the StatefulComponent should actually - wrap the parent component of the child instead of recursing into the children - and memoizing them independently. - - Args: - child: The child component. - - Returns: - The Var from the child component or the child itself (for regular cases). - """ - from reflex_components_core.base.bare import Bare - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.foreach import Foreach - from reflex_components_core.core.match import Match - - if isinstance(child, Bare): - return child.contents - if isinstance(child, Cond): - return child.cond - if isinstance(child, Foreach): - return child.iterable - if isinstance(child, Match): - return child.cond - return child - - @classmethod - def _get_tag_name(cls, component: Component) -> str | None: - """Get the tag based on rendering the given component. - - Args: - component: The component to render. - - Returns: - The tag for the stateful component. - """ - # Get the render dict for the component. - rendered_code = component.render() - if not rendered_code: - # Never memoize non-visual components. - return None - - # Compute the hash based on the rendered code. - code_hash = _hash_str(_deterministic_hash(rendered_code)) - - # Format the tag name including the hash. - return format.format_state_name( - f"{component.tag or 'Comp'}_{code_hash}" - ).capitalize() - - def _render_stateful_code( - self, - export: bool = False, - ) -> str: - if not self.tag: - return "" - # Render the code for this component and hooks. - return stateful_component_template( - tag_name=self.tag, - memo_trigger_hooks=self.memo_trigger_hooks, - component=self.component, - export=export, - ) - - @classmethod - def _fix_event_triggers( - cls, - component: Component, - ) -> list[str]: - """Render the code for a stateful component. - - Args: - component: The component to render. - - Returns: - The memoized event trigger hooks for the component. - """ - # Memoize event triggers useCallback to avoid unnecessary re-renders. - memo_event_triggers = tuple(cls._get_memoized_event_triggers(component).items()) - - # Trigger hooks stored separately to write after the normal hooks (see stateful_component.js.jinja2) - memo_trigger_hooks: list[str] = [] - - if memo_event_triggers: - # Copy the component to avoid mutating the original. - component = copy.copy(component) - - for event_trigger, ( - memo_trigger, - memo_trigger_hook, - ) in memo_event_triggers: - # Replace the event trigger with the memoized version. - memo_trigger_hooks.append(memo_trigger_hook) - component.event_triggers[event_trigger] = memo_trigger - - return memo_trigger_hooks - - @staticmethod - def _get_hook_deps(hook: str) -> list[str]: - """Extract var deps from a hook. - - Args: - hook: The hook line to extract deps from. - - Returns: - A list of var names created by the hook declaration. - """ - # Ensure that the hook is a var declaration. - var_decl = hook.partition("=")[0].strip() - if not any(var_decl.startswith(kw) for kw in ["const ", "let ", "var "]): - return [] - - # Extract the var name from the declaration. - _, _, var_name = var_decl.partition(" ") - var_name = var_name.strip() - - # Break up array and object destructuring if used. - if var_name.startswith(("[", "{")): - return [ - v.strip().replace("...", "") for v in var_name.strip("[]{}").split(",") - ] - return [var_name] - - @staticmethod - def _get_deps_from_event_trigger( - event: EventChain | EventSpec | Var, - ) -> dict[str, None]: - """Get the dependencies accessed by event triggers. - - Args: - event: The event trigger to extract deps from. - - Returns: - The dependencies accessed by the event triggers. - """ - events: list = [event] - deps = {} - - if isinstance(event, EventChain): - events.extend(event.events) - - for ev in events: - if isinstance(ev, EventSpec): - for arg in ev.args: - for a in arg: - var_datas = VarData.merge(a._get_all_var_data()) - if var_datas and var_datas.deps is not None: - deps |= {str(dep): None for dep in var_datas.deps} - return deps - - @classmethod - def _get_memoized_event_triggers( - cls, - component: Component, - ) -> dict[str, tuple[Var, str]]: - """Memoize event handler functions with useCallback to avoid unnecessary re-renders. - - Args: - component: The component with events to memoize. - - Returns: - A dict of event trigger name to a tuple of the memoized event trigger Var and - the hook code that memoizes the event handler. - """ - trigger_memo = {} - for event_trigger, event_args in component._get_vars_from_event_triggers( - component.event_triggers - ): - if event_trigger in { - EventTriggers.ON_MOUNT, - EventTriggers.ON_UNMOUNT, - EventTriggers.ON_SUBMIT, - }: - # Do not memoize lifecycle or submit events. - continue - - # Get the actual EventSpec and render it. - event = component.event_triggers[event_trigger] - rendered_chain = str(LiteralVar.create(event)) - - # Hash the rendered EventChain to get a deterministic function name. - chain_hash = md5(str(rendered_chain).encode("utf-8")).hexdigest() - memo_name = f"{event_trigger}_{chain_hash}" - - # Calculate Var dependencies accessed by the handler for useCallback dep array. - var_deps = ["addEvents", "ReflexEvent"] - - # Get deps from event trigger var data. - var_deps.extend(cls._get_deps_from_event_trigger(event)) - - # Get deps from hooks. - for arg in event_args: - var_data = arg._get_all_var_data() - if var_data is None: - continue - for hook in var_data.hooks: - var_deps.extend(cls._get_hook_deps(hook)) - memo_var_data = VarData.merge( - *[var._get_all_var_data() for var in event_args], - VarData( - imports={"react": [ImportVar(tag="useCallback")]}, - ), - ) - - # Store the memoized function name and hook code for this event trigger. - trigger_memo[event_trigger] = ( - Var(_js_expr=memo_name)._replace( - _var_type=EventChain, merge_var_data=memo_var_data - ), - f"const {memo_name} = useCallback({rendered_chain}, [{', '.join(var_deps)}])", - ) - return trigger_memo - - def _get_all_hooks_internal(self) -> dict[str, VarData | None]: - """Get the reflex internal hooks for the component and its children. - - Returns: - The code that should appear just before user-defined hooks. - """ - return {} - - def _get_all_hooks(self) -> dict[str, VarData | None]: - """Get the React hooks for this component. - - Returns: - The code that should appear just before returning the rendered component. - """ - return {} - - def _get_all_imports(self) -> ParsedImportDict: - """Get all the libraries and fields that are used by the component. - - Returns: - The import dict with the required imports. - """ - if self.rendered_as_shared: - return { - f"$/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [ - ImportVar(tag=self.tag) - ] - } - return self.component._get_all_imports() - - def _get_all_dynamic_imports(self) -> set[str]: - """Get dynamic imports for the component. - - Returns: - The dynamic imports. - """ - if self.rendered_as_shared: - return set() - return self.component._get_all_dynamic_imports() - - def _get_all_custom_code(self, export: bool = False) -> dict[str, None]: - """Get custom code for the component. - - Args: - export: Whether to export the component. - - Returns: - The custom code. - """ - if self.rendered_as_shared: - return {} - return self.component._get_all_custom_code() | ({ - self._render_stateful_code(export=export): None - }) - - def _get_all_refs(self) -> dict[str, None]: - """Get the refs for the children of the component. - - Returns: - The refs for the children. - """ - if self.rendered_as_shared: - return {} - return self.component._get_all_refs() - - def render(self) -> dict: - """Define how to render the component in React. - - Returns: - The tag to render. - """ - return dict(Tag(name=self.tag or "")) - - def __str__(self) -> str: - """Represent the component in React. - - Returns: - The code to render the component. - """ - from reflex.compiler.compiler import _compile_component - - return _compile_component(self) - - @classmethod - def compile_from( - cls, - component: BaseComponent, - *, - stateful_component_cache: dict[str, StatefulComponent] | None = None, - ) -> BaseComponent: - """Walk through the component tree and memoize all stateful components. - - Args: - component: The component to memoize. - stateful_component_cache: Compile-scoped cache of memoized components. - - Returns: - The memoized component tree. - """ - stateful_component_cache = ( - stateful_component_cache if stateful_component_cache is not None else {} - ) - if isinstance(component, Component): - if component._memoization_mode.recursive: - # Recursively memoize stateful children (default). - component.children = [ - cls.compile_from( - child, - stateful_component_cache=stateful_component_cache, - ) - for child in component.children - ] - # Memoize this component if it depends on state. - stateful_component = cls.create( - component, - stateful_component_cache=stateful_component_cache, - ) - if stateful_component is not None: - return stateful_component - return component - - class MemoizationLeaf(Component): """A component that does not separately memoize its children. diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 6c2100a40e8..0386167198d 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -85,9 +85,8 @@ def make_component(component: Component) -> str: rendered_components.update(component._get_all_custom_code()) rendered_components[ - templates.stateful_component_template( + templates.dynamic_component_template( tag_name="MySSRComponent", - memo_trigger_hooks=[], component=component, export=True, ) @@ -110,7 +109,7 @@ def make_component(component: Component) -> str: else: imports[lib] = names - module_code_lines = templates.stateful_components_template( + module_code_lines = templates.dynamic_components_module_template( imports=utils.compile_imports(imports), memoized_code="\n".join(rendered_components), ).splitlines() diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py new file mode 100644 index 00000000000..c7494ba6b8c --- /dev/null +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -0,0 +1,175 @@ +"""Event-trigger memoization helpers for auto-memoized and pseudo-stateful components. + +These helpers wrap a component's non-lifecycle event triggers in ``useCallback`` +so that React can skip re-renders of subtrees whose event handlers have stable +identities. They are used by both the compiler auto-memoization plugin (see +``reflex.compiler.plugins.memoize``) and by component-creation-time consumers +in ``reflex-components-core`` (e.g. ``WindowEventListener``, ``upload``). +""" + +from __future__ import annotations + +import contextlib +from hashlib import md5 + +from reflex_base.components.component import Component +from reflex_base.constants import EventTriggers +from reflex_base.event import EventChain, EventSpec +from reflex_base.utils.imports import ImportVar +from reflex_base.vars import VarData +from reflex_base.vars.base import LiteralVar, Var + + +def _get_hook_deps(hook: str) -> list[str]: + """Extract Var deps from a hook declaration line. + + Args: + hook: The hook line (e.g. ``"const foo = useState(...)"``). + + Returns: + The names of variables created by the declaration. + """ + var_decl = hook.partition("=")[0].strip() + if not any(var_decl.startswith(kw) for kw in ["const ", "let ", "var "]): + return [] + _, _, var_name = var_decl.partition(" ") + var_name = var_name.strip() + if var_name.startswith(("[", "{")): + return [v.strip().replace("...", "") for v in var_name.strip("[]{}").split(",")] + return [var_name] + + +def _get_deps_from_event_trigger( + event: EventChain | EventSpec | Var, +) -> dict[str, None]: + """Get the dependencies accessed by an event trigger value. + + Args: + event: The event trigger value. + + Returns: + Dependency names, insertion-ordered. + """ + events: list = [event] + deps: dict[str, None] = {} + + if isinstance(event, EventChain): + events.extend(event.events) + + for ev in events: + if isinstance(ev, EventSpec): + for arg in ev.args: + for a in arg: + var_datas = VarData.merge(a._get_all_var_data()) + if var_datas and var_datas.deps is not None: + deps |= {str(dep): None for dep in var_datas.deps} + return deps + + +def get_memoized_event_triggers( + component: Component, +) -> dict[str, tuple[Var, str]]: + """Generate ``useCallback`` wrappers for the component's event triggers. + + Args: + component: The component whose event triggers should be memoized. + + Returns: + A dict mapping event trigger name to + ``(memoized_var, useCallback_hook_line)``. + """ + trigger_memo: dict[str, tuple[Var, str]] = {} + for event_trigger, event_args in component._get_vars_from_event_triggers( + component.event_triggers + ): + if event_trigger in { + EventTriggers.ON_MOUNT, + EventTriggers.ON_UNMOUNT, + EventTriggers.ON_SUBMIT, + }: + # Do not memoize lifecycle or submit events. + continue + + event = component.event_triggers[event_trigger] + rendered_chain = str(LiteralVar.create(event)) + + chain_hash = md5( + str(rendered_chain).encode("utf-8"), usedforsecurity=False + ).hexdigest() + memo_name = f"{event_trigger}_{chain_hash}" + + var_deps = ["addEvents", "ReflexEvent"] + var_deps.extend(_get_deps_from_event_trigger(event)) + + for arg in event_args: + var_data = arg._get_all_var_data() + if var_data is None: + continue + for hook in var_data.hooks: + var_deps.extend(_get_hook_deps(hook)) + + memo_var_data = VarData.merge( + *[var._get_all_var_data() for var in event_args], + VarData(imports={"react": [ImportVar(tag="useCallback")]}), + ) + + trigger_memo[event_trigger] = ( + Var(_js_expr=memo_name)._replace( + _var_type=EventChain, merge_var_data=memo_var_data + ), + f"const {memo_name} = useCallback({rendered_chain}, [{', '.join(var_deps)}])", + ) + return trigger_memo + + +def fix_event_triggers_for_memo(component: Component) -> list[str]: + """Memoize ``component.event_triggers`` in place and return hook code. + + Replaces each (non-lifecycle) event-trigger value on ``component`` with a + ``Var`` naming a memoized ``useCallback`` wrapper, and returns the + ``useCallback`` hook lines in trigger order. + + Args: + component: The component whose event triggers to memoize. + + Returns: + The ``useCallback`` hook lines to emit at the top of the page body. + """ + memo_event_triggers = tuple(get_memoized_event_triggers(component).items()) + memo_trigger_hooks: list[str] = [] + + if memo_event_triggers: + component.event_triggers = dict( + component.event_triggers + ) # isolate so original dict is not mutated + for event_trigger, (memo_trigger, memo_trigger_hook) in memo_event_triggers: + memo_trigger_hooks.append(memo_trigger_hook) + component.event_triggers[event_trigger] = memo_trigger + + return memo_trigger_hooks + + +def invalidate_event_trigger_caches(component: Component) -> None: + """Drop caches that depend on ``component.event_triggers``. + + After :func:`fix_event_triggers_for_memo` mutates the shared event-triggers + dict, cached derivatives become stale. + + Args: + component: The original (pre-mutation) component. + """ + for attr in ( + "_cached_render_result", + "_vars_cache", + "_imports_cache", + "_hooks_internal_cache", + ): + with contextlib.suppress(AttributeError): + delattr(component, attr) + + +__all__ = [ + "fix_event_triggers_for_memo", + "get_memoized_event_triggers", + "invalidate_event_trigger_caches", +] diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index dd542afcb4c..f3ef5aa971c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -9,6 +9,7 @@ CompilerHooks, ComponentAndChildren, PageContext, + PageDefinition, ) from .sitemap import SitemapPlugin from .tailwind_v3 import TailwindV3Plugin @@ -21,6 +22,7 @@ "CompilerHooks", "ComponentAndChildren", "PageContext", + "PageDefinition", "Plugin", "PreCompileContext", "SitemapPlugin", diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index c74f4a8a98b..fdd8911a7f5 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from reflex.app import App, UnevaluatedPage - from reflex_base.components.component import BaseComponent, StatefulComponent + from reflex_base.components.component import BaseComponent from reflex_base.plugins.compiler import ComponentAndChildren, PageContext @@ -155,7 +155,6 @@ def enter_component( page_context: "PageContext", compile_context: Any, in_prop_tree: bool = False, - stateful_component: "StatefulComponent | None" = None, ) -> "BaseComponent | ComponentAndChildren | None": """Inspect or transform a component before visiting its descendants. @@ -164,12 +163,10 @@ def enter_component( page_context: The active page compilation state. compile_context: The active compile-run state. in_prop_tree: Whether the component is being visited through a prop subtree. - stateful_component: The surrounding stateful component, when applicable. Returns: An optional replacement component and/or structural children. """ - del comp, page_context, compile_context, in_prop_tree, stateful_component return None def leave_component( @@ -181,7 +178,6 @@ def leave_component( page_context: "PageContext", compile_context: Any, in_prop_tree: bool = False, - stateful_component: "StatefulComponent | None" = None, ) -> "BaseComponent | ComponentAndChildren | None": """Inspect or transform a component after visiting its descendants. @@ -191,19 +187,10 @@ def leave_component( page_context: The active page compilation state. compile_context: The active compile-run state. in_prop_tree: Whether the component is being visited through a prop subtree. - stateful_component: The surrounding stateful component, when applicable. Returns: An optional replacement component and/or structural children. """ - del ( - comp, - children, - page_context, - compile_context, - in_prop_tree, - stateful_component, - ) return None def __repr__(self): diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 8431c274f3c..548fc9516a1 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -7,18 +7,18 @@ from collections.abc import Callable, Sequence from contextvars import ContextVar, Token from types import TracebackType -from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, cast from typing_extensions import Self -from reflex_base.components.component import BaseComponent, Component, StatefulComponent +from reflex_base.components.component import BaseComponent, Component from reflex_base.utils.imports import ParsedImportDict, collapse_imports, merge_imports from reflex_base.vars import VarData from .base import Plugin if TYPE_CHECKING: - from reflex.app import App, ComponentCallable, UnevaluatedPage + from reflex.app import App, ComponentCallable PageComponent: TypeAlias = Component | ComponentCallable else: @@ -31,14 +31,28 @@ ) +class PageDefinition(Protocol): + """Protocol for page-like objects compiled by :class:`CompileContext`.""" + + @property + def route(self) -> str: + """Return the route for this page definition.""" + ... + + @property + def component(self) -> PageComponent: + """Return the component or callable for this page definition.""" + ... + + ComponentAndChildren: TypeAlias = tuple[BaseComponent, tuple[BaseComponent, ...]] ComponentReplacement: TypeAlias = BaseComponent | ComponentAndChildren | None CompiledEnterHook: TypeAlias = Callable[ - [BaseComponent, bool, StatefulComponent | None], + [BaseComponent, bool], ComponentReplacement, ] CompiledLeaveHook: TypeAlias = Callable[ - [BaseComponent, tuple[BaseComponent, ...], bool, StatefulComponent | None], + [BaseComponent, tuple[BaseComponent, ...], bool], ComponentReplacement, ] EnterHookBinder: TypeAlias = Callable[ @@ -68,11 +82,13 @@ class CompilerHooks: init=False, repr=False, ) - _leave_component_hook_binders: tuple[tuple[LeaveHookBinder, bool], ...] = ( - dataclasses.field( - init=False, - repr=False, - ) + _leave_component_hook_binders: tuple[LeaveHookBinder, ...] = dataclasses.field( + init=False, + repr=False, + ) + _component_hooks_can_replace: bool = dataclasses.field( + init=False, + repr=False, ) def __post_init__(self) -> None: @@ -84,7 +100,8 @@ def __post_init__(self) -> None: self._resolve_hooks("compile_page"), ) enter_hook_binders: list[EnterHookBinder] = [] - leave_hook_binders: list[tuple[LeaveHookBinder, bool]] = [] + leave_hook_binders: list[LeaveHookBinder] = [] + component_hooks_can_replace = False for plugin in self.plugins: if ( @@ -93,23 +110,28 @@ def __post_init__(self) -> None: enter_hook_binders.append( self._get_enter_hook_binder(plugin, hook_impl) ) + component_hooks_can_replace = component_hooks_can_replace or bool( + getattr( + type(plugin), + "_compiler_can_replace_enter_component", + True, + ) + ) if ( hook_impl := self._get_hook_impl(plugin, "leave_component") ) is not None: - stateful_only = bool( + leave_hook_binders.append( + self._get_leave_hook_binder(plugin, hook_impl) + ) + component_hooks_can_replace = component_hooks_can_replace or bool( getattr( type(plugin), - "_compiler_stateful_only_leave_component", - False, + "_compiler_can_replace_leave_component", + True, ) ) - leave_hook_binders.append(( - self._get_leave_hook_binder(plugin, hook_impl), - stateful_only, - )) - reversed_leave_hook_binders = tuple(reversed(tuple(leave_hook_binders))) object.__setattr__( self, "_enter_component_hook_binders", @@ -118,7 +140,12 @@ def __post_init__(self) -> None: object.__setattr__( self, "_leave_component_hook_binders", - reversed_leave_hook_binders, + tuple(reversed(tuple(leave_hook_binders))), + ) + object.__setattr__( + self, + "_component_hooks_can_replace", + component_hooks_can_replace, ) @staticmethod @@ -143,7 +170,7 @@ def _get_hook_impl( if plugin_impl is inspect.getattr_static(Plugin, hook_name, None): return None - return getattr(plugin, hook_name, None) + return cast(Callable[..., Any], getattr(plugin, hook_name, None)) def _resolve_hooks(self, hook_name: str) -> tuple[Callable[..., Any], ...]: """Resolve concrete hook implementations for the plugin chain. @@ -177,7 +204,6 @@ def bind( def enter_component( comp: BaseComponent, in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> ComponentReplacement: return cast( ComponentReplacement, @@ -186,7 +212,6 @@ def enter_component( page_context=page_context, compile_context=compile_context, in_prop_tree=in_prop_tree, - stateful_component=stateful_component, ), ) @@ -212,7 +237,6 @@ def leave_component( comp: BaseComponent, children: tuple[BaseComponent, ...], in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> ComponentReplacement: return cast( ComponentReplacement, @@ -222,7 +246,6 @@ def leave_component( page_context=page_context, compile_context=compile_context, in_prop_tree=in_prop_tree, - stateful_component=stateful_component, ), ) @@ -235,7 +258,7 @@ def eval_page( page_fn: PageComponent, /, *, - page: UnevaluatedPage, + page: PageDefinition, **kwargs: Any, ) -> PageContext | None: """Return the first page context produced by the plugin chain.""" @@ -263,7 +286,6 @@ def compile_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> BaseComponent: """Walk a component tree once while dispatching cached enter/leave hooks. @@ -274,39 +296,171 @@ def compile_component( hook_binder(page_context, compile_context) for hook_binder in self._enter_component_hook_binders ) - leave_hooks = tuple( - (hook_binder(page_context, compile_context), stateful_only) - for hook_binder, stateful_only in self._leave_component_hook_binders - ) - return self._compile_component_tree( + if not self._component_hooks_can_replace: + leave_hooks = tuple( + hook_binder(page_context, compile_context) + for hook_binder in self._leave_component_hook_binders + ) + + if len(enter_hooks) == 1 and not leave_hooks: + return self._compile_component_single_enter_fast_path( + comp, + enter_hook=enter_hooks[0], + in_prop_tree=in_prop_tree, + ) + + return self._compile_component_without_replacements( + comp, + enter_hooks=enter_hooks, + leave_hooks=leave_hooks, + in_prop_tree=in_prop_tree, + ) + + return self._compile_component_with_replacements( comp, enter_hooks=enter_hooks, - leave_hooks=leave_hooks, + leave_hooks=tuple( + hook_binder(page_context, compile_context) + for hook_binder in self._leave_component_hook_binders + ), in_prop_tree=in_prop_tree, - stateful_component=stateful_component, ) - def _compile_component_tree( + def _compile_component_without_replacements( + self, + comp: BaseComponent, + /, + *, + enter_hooks: tuple[CompiledEnterHook, ...], + leave_hooks: tuple[CompiledLeaveHook, ...], + in_prop_tree: bool = False, + ) -> BaseComponent: + """Walk a component tree when hook plans only observe state. + + Returns: + The compiled component root for this subtree. + """ + + def visit( + current_comp: BaseComponent, + current_in_prop_tree: bool, + ) -> BaseComponent: + for hook_impl in enter_hooks: + hook_impl( + current_comp, + current_in_prop_tree, + ) + + updated_children: list[BaseComponent] | None = None + children = current_comp.children + for index, child in enumerate(children): + compiled_child = visit( + child, + current_in_prop_tree, + ) + if updated_children is None: + if compiled_child is child: + continue + updated_children = list(children[:index]) + updated_children.append(compiled_child) + if updated_children is not None: + current_comp.children = updated_children + + if isinstance(current_comp, Component): + for prop_component in current_comp._get_components_in_props(): + visit( + prop_component, + True, + ) + + if leave_hooks: + compiled_children = tuple(current_comp.children) + for hook_impl in leave_hooks: + hook_impl( + current_comp, + compiled_children, + current_in_prop_tree, + ) + + return current_comp + + return visit( + comp, + in_prop_tree, + ) + + def _compile_component_single_enter_fast_path( + self, + comp: BaseComponent, + /, + *, + enter_hook: CompiledEnterHook, + in_prop_tree: bool = False, + ) -> BaseComponent: + """Walk a component tree for the common one-enter-hook fast path. + + Returns: + The compiled component root for this subtree. + """ + + def visit( + current_comp: BaseComponent, + current_in_prop_tree: bool, + ) -> BaseComponent: + enter_hook( + current_comp, + current_in_prop_tree, + ) + + updated_children: list[BaseComponent] | None = None + children = current_comp.children + for index, child in enumerate(children): + compiled_child = visit( + child, + current_in_prop_tree, + ) + if updated_children is None: + if compiled_child is child: + continue + updated_children = list(children[:index]) + updated_children.append(compiled_child) + if updated_children is not None: + current_comp.children = updated_children + + if isinstance(current_comp, Component): + for prop_component in current_comp._get_components_in_props(): + visit( + prop_component, + True, + ) + + return current_comp + + return visit( + comp, + in_prop_tree, + ) + + def _compile_component_with_replacements( self, comp: BaseComponent, /, *, enter_hooks: tuple[CompiledEnterHook, ...], - leave_hooks: tuple[tuple[CompiledLeaveHook, bool], ...], + leave_hooks: tuple[CompiledLeaveHook, ...], in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> BaseComponent: - """Walk a component tree dispatching enter/leave hooks. + """Walk a component tree while honoring hook replacements. Returns: The compiled component root for this subtree. """ + apply_replacement = self._apply_replacement def visit_children( children: Sequence[BaseComponent], current_in_prop_tree: bool, - current_stateful_component: StatefulComponent | None, ) -> tuple[BaseComponent, ...]: if not children: return () @@ -316,7 +470,6 @@ def visit_children( compiled_child = visit( child, current_in_prop_tree, - current_stateful_component, ) if updated_children is None: if compiled_child is child: @@ -330,76 +483,49 @@ def visit_children( def visit( current_comp: BaseComponent, current_in_prop_tree: bool, - current_stateful_component: StatefulComponent | None, ) -> BaseComponent: compiled_component = current_comp structural_children: tuple[BaseComponent, ...] | None = None for hook_impl in enter_hooks: - replacement = hook_impl( + compiled_component, structural_children = apply_replacement( compiled_component, - current_in_prop_tree, - current_stateful_component, - ) - if replacement is not None: - if isinstance(replacement, tuple): - compiled_component = cast(BaseComponent, replacement[0]) - structural_children = cast( - tuple[BaseComponent, ...], replacement[1] - ) - else: - compiled_component = replacement - - if isinstance(compiled_component, StatefulComponent): - if not compiled_component.rendered_as_shared: - compiled_component.component = cast( - Component, - visit( - compiled_component.component, - current_in_prop_tree, - compiled_component, - ), - ) - compiled_children = tuple(compiled_component.children) - else: - if structural_children is None: - structural_children = tuple(compiled_component.children) - compiled_children = visit_children( structural_children, - current_in_prop_tree, - current_stateful_component, + hook_impl( + compiled_component, + current_in_prop_tree, + ), ) - if isinstance(compiled_component, Component): - for prop_component in compiled_component._get_components_in_props(): - visit( - prop_component, - True, - current_stateful_component, - ) - - is_stateful_component = isinstance(compiled_component, StatefulComponent) - for hook_impl, stateful_only in leave_hooks: - if stateful_only and not is_stateful_component: - continue - replacement = hook_impl( + + if structural_children is None: + structural_children = tuple(compiled_component.children) + compiled_children = visit_children( + structural_children, + current_in_prop_tree, + ) + if isinstance(compiled_component, Component): + for prop_component in compiled_component._get_components_in_props(): + visit( + prop_component, + True, + ) + + for hook_impl in leave_hooks: + compiled_component, replacement_children = apply_replacement( compiled_component, compiled_children, - current_in_prop_tree, - current_stateful_component, + hook_impl( + compiled_component, + compiled_children, + current_in_prop_tree, + ), ) - if replacement is not None: - if isinstance(replacement, tuple): - compiled_component = cast(BaseComponent, replacement[0]) - new_children = cast(tuple[BaseComponent, ...], replacement[1]) - else: - compiled_component = replacement - new_children = compiled_children - if new_children is not compiled_children: - compiled_children = visit_children( - new_children, - current_in_prop_tree, - current_stateful_component, - ) + if replacement_children is not compiled_children: + assert replacement_children is not None + compiled_children = visit_children( + replacement_children, + current_in_prop_tree, + ) compiled_component.children = list(compiled_children) return compiled_component @@ -407,9 +533,30 @@ def visit( return visit( comp, in_prop_tree, - stateful_component, ) + @staticmethod + def _apply_replacement( + comp: BaseComponent, + children: tuple[BaseComponent, ...] | None, + replacement: ComponentReplacement, + ) -> tuple[BaseComponent, tuple[BaseComponent, ...] | None]: + """Apply a plugin replacement to the current component state. + + Args: + comp: The current component. + children: The current structural children. + replacement: The plugin-supplied replacement. + + Returns: + The updated component and structural children pair. + """ + if replacement is None: + return comp, children + if isinstance(replacement, tuple): + return replacement + return replacement, children + @dataclasses.dataclass(kw_only=True) class BaseContext: @@ -546,7 +693,7 @@ class CompileContext(BaseContext): """Mutable compilation state for an entire compile run.""" app: App | None = None - pages: Sequence[UnevaluatedPage] + pages: Sequence[PageDefinition] hooks: CompilerHooks = dataclasses.field(default_factory=CompilerHooks) compiled_pages: dict[str, PageContext] = dataclasses.field(default_factory=dict) all_imports: ParsedImportDict = dataclasses.field(default_factory=dict) @@ -554,8 +701,13 @@ class CompileContext(BaseContext): default_factory=dict ) stateful_routes: dict[str, None] = dataclasses.field(default_factory=dict) - stateful_components_path: str | None = None - stateful_components_code: str = "" + # Auto-memoize wrapper tags seen during the tree walk (populated by + # ``MemoizeStatefulPlugin``). + memoize_wrappers: dict[str, None] = dataclasses.field(default_factory=dict) + # Compiler-generated experimental memo definitions for auto-memoized + # stateful wrappers. Stored as ``Any`` to keep ``reflex_base`` decoupled + # from ``reflex.experimental.memo``. + auto_memo_components: dict[str, Any] = dataclasses.field(default_factory=dict) def compile( self, @@ -576,16 +728,14 @@ def compile( """ from reflex.compiler import compiler from reflex.state import all_base_state_classes - from reflex.utils.exec import is_prod_mode self.ensure_context_attached() self.compiled_pages.clear() self.all_imports.clear() self.app_wrap_components.clear() self.stateful_routes.clear() - self.stateful_components_path = compiler.utils.get_stateful_components_path() - self.stateful_components_code = "" - stateful_component_cache: dict[str, StatefulComponent] = {} + self.memoize_wrappers.clear() + self.auto_memo_components.clear() for page in self.pages: page_fn = page.component @@ -610,39 +760,11 @@ def compile( if len(all_base_state_classes) > n_states_before: self.stateful_routes[page.route] = None - if isinstance(page_ctx.root_component, StatefulComponent): - self.app_wrap_components.update( - page_ctx.root_component.component._get_all_app_wrap_components() - ) - elif isinstance(page_ctx.root_component, Component): - self.app_wrap_components.update( - page_ctx.root_component._get_all_app_wrap_components() - ) - - page_ctx.root_component = ( - StatefulComponent.compile_from( - page_ctx.root_component, - stateful_component_cache=stateful_component_cache, - ) - or page_ctx.root_component - ) self.compiled_pages[page_ctx.route] = page_ctx if evaluate_progress is not None: evaluate_progress() - page_components = [ - page_ctx.root_component for page_ctx in self.compiled_pages.values() - ] - stateful_imports: ParsedImportDict = {} - if is_prod_mode(): - self.stateful_components_code, stateful_imports = ( - compiler._compile_stateful_components(page_components) - ) - self.all_imports = merge_imports(self.all_imports, stateful_imports) - else: - self.stateful_components_code = "" - for page, page_ctx in zip( self.pages, self.compiled_pages.values(), @@ -682,5 +804,5 @@ def compile( "CompilerHooks", "ComponentAndChildren", "PageContext", - "Plugin", + "PageDefinition", ] diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 8caa1d2b2c3..71b4d723e5e 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from reflex.state import BaseState - from reflex_base.components.component import StatefulComponent from reflex_base.event import EventHandler @@ -40,10 +39,6 @@ class RegistrationContext(BaseContext): default_factory=dict, repr=False, ) - tag_to_stateful_component: dict[str, StatefulComponent] = dataclasses.field( - default_factory=dict, - repr=False, - ) @classmethod def ensure_context(cls) -> Self: diff --git a/packages/reflex-components-core/src/reflex_components_core/core/upload.py b/packages/reflex-components-core/src/reflex_components_core/core/upload.py index 84b3ef06f06..ab020eb9884 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/upload.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/upload.py @@ -10,9 +10,9 @@ Component, ComponentNamespace, MemoizationLeaf, - StatefulComponent, field, ) +from reflex_base.components.memoize_helpers import get_memoized_event_triggers from reflex_base.constants import Dirs from reflex_base.constants.compiler import Hooks, Imports from reflex_base.environment import environment @@ -357,7 +357,7 @@ def create(cls, *children, **props) -> Component: ), ) - event_triggers = StatefulComponent._get_memoized_event_triggers( + event_triggers = get_memoized_event_triggers( GhostUpload.create( on_drop=upload_props["on_drop"], on_drop_rejected=upload_props["on_drop_rejected"], diff --git a/packages/reflex-components-core/src/reflex_components_core/core/window_events.py b/packages/reflex-components-core/src/reflex_components_core/core/window_events.py index debb4c3dc37..10a7362188d 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/window_events.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/window_events.py @@ -4,7 +4,7 @@ from typing import Any, cast -from reflex_base.components.component import StatefulComponent, field +from reflex_base.components.component import field from reflex_base.constants.compiler import Hooks from reflex_base.event import EventHandler, key_event, no_args_event_spec from reflex_base.vars.base import Var, VarData @@ -95,8 +95,10 @@ def create(cls, **props) -> WindowEventListener: Returns: The created component. """ + from reflex_base.components.memoize_helpers import fix_event_triggers_for_memo + real_component = cast("WindowEventListener", super().create(**props)) - hooks = StatefulComponent._fix_event_triggers(real_component) + hooks = fix_event_triggers_for_memo(real_component) real_component.hooks = hooks return real_component diff --git a/pyi_hashes.json b/pyi_hashes.json index f7900836abd..64d50de290a 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -20,8 +20,8 @@ "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5e28d554d2b4d7fae1ba35809c24f4fc", "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "28bd59898f0402b33c34e14f3eef1282", "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "6f3cdef9956dbe5c917edeefdffd1b0e", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "28e901ee970bec806ee766d0d126d739", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "8619aba44cf2568a5c45de9975251722", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "dbefc8e2ec126b4ed878d69d0d233999", "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "1a8824cdd243efc876157b97f9f1b714", @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "dc43e142b089b1158588e999505444f6", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" + "reflex/experimental/memo.pyi": "65306e737dac21981bdb361da84d43db" } diff --git a/reflex/app.py b/reflex/app.py index f7b757abf41..cd8c64d2a1c 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -867,8 +867,10 @@ def _compile_page(self, route: str, save_page: bool = True): """ n_states_before = len(all_base_state_classes) component = compiler.compile_unevaluated_page( + route, self._unevaluated_pages[route], - style=self.style, + self.style, + self.theme, ) # Indicate that evaluating this page creates one or more state classes. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 07d18fdcf3b..de941cd4a0e 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule from pathlib import Path @@ -15,7 +16,6 @@ Component, ComponentStyle, CustomComponent, - StatefulComponent, evaluate_style_namespaces, ) from reflex_base.config import get_config @@ -26,7 +26,7 @@ from reflex_base.style import SYSTEM_COLOR_MODE from reflex_base.utils.exceptions import ReflexError from reflex_base.utils.format import to_title_case -from reflex_base.utils.imports import ImportVar, ParsedImportDict +from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import LiteralVar, Var from reflex_components_core.base.app_wrap import AppWrap from reflex_components_core.base.fragment import Fragment @@ -347,7 +347,7 @@ def _compile_root_stylesheet(stylesheets: list[str], reset_style: bool = True) - return templates.styles_template(stylesheets=sheets) -def _compile_component(component: Component | StatefulComponent) -> str: +def _compile_component(component: Component) -> str: """Compile a single component. Args: @@ -428,88 +428,6 @@ def _compile_memo_components( ) -def _get_shared_components_recursive( - component: BaseComponent, - rendered_components: dict[str, None], - all_import_dicts: list[ParsedImportDict], -): - """Get the shared components for a component and its children. - - A shared component is a StatefulComponent that appears in 2 or more - pages and is a candidate for writing to a common file and importing - into each page where it is used. - - Args: - component: The component to collect shared StatefulComponents for. - rendered_components: A dict to store the rendered shared components in. - all_import_dicts: A list to store the imports of all shared components in. - """ - for child in component.children: - # Depth-first traversal. - _get_shared_components_recursive(child, rendered_components, all_import_dicts) - - # When the component is referenced by more than one page, render it - # to be included in the STATEFUL_COMPONENTS module. - # Skip this step in dev mode, thereby avoiding potential hot reload errors for larger apps - if isinstance(component, StatefulComponent) and component.references > 1: - # Reset this flag to render the actual component. - component.rendered_as_shared = False - - # Include dynamic imports in the shared component. - if dynamic_imports := component._get_all_dynamic_imports(): - rendered_components.update(dict.fromkeys(dynamic_imports)) - - # Include custom code in the shared component. - rendered_components.update(component._get_all_custom_code(export=True)) - - # Include all imports in the shared component. - all_import_dicts.append(component._get_all_imports()) - - # Indicate that this component now imports from the shared file. - component.rendered_as_shared = True - - -def _compile_stateful_components( - page_components: list[BaseComponent], -) -> tuple[str, ParsedImportDict]: - """Walk the page components and extract shared stateful components. - - Any StatefulComponent that is shared by more than one page will be rendered - to a separate module and marked rendered_as_shared so subsequent - renderings will import the component from the shared module instead of - directly including the code for it. - - Args: - page_components: The Components or StatefulComponents to compile. - - Returns: - The rendered stateful components code and imports. - """ - all_import_dicts = [] - rendered_components = {} - - for page_component in page_components: - _get_shared_components_recursive( - page_component, rendered_components, all_import_dicts - ) - - # Don't import from the file that we're about to create. - all_imports = utils.merge_imports(*all_import_dicts) - all_imports.pop( - f"$/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None - ) - if rendered_components: - _apply_common_imports(all_imports) - - return ( - templates.stateful_components_template( - imports=utils.compile_imports(all_imports), - memoized_code="\n".join(rendered_components), - ), - all_imports, - ) - - def compile_document_root( head_components: list[Component], html_lang: str | None = None, @@ -664,43 +582,6 @@ def compile_memo_components( return output_path, code, imports -def compile_stateful_components( - pages: Iterable[Component], - progress_function: Callable[[], None], -) -> tuple[str, str, list[BaseComponent]]: - """Separately compile components that depend on State vars. - - StatefulComponents are compiled as their own component functions with their own - useContext declarations, which allows page components to be stateless and avoid - re-rendering along with parts of the page that actually depend on state. - - Args: - pages: The pages to extract stateful components from. - progress_function: A function to call to indicate progress, called once per page. - - Returns: - The path and code of the compiled stateful components. - """ - output_path = utils.get_stateful_components_path() - - stateful_component_cache: dict[str, StatefulComponent] = {} - page_components = [] - for page in pages: - # Compile the stateful components - page_component = ( - StatefulComponent.compile_from( - page, - stateful_component_cache=stateful_component_cache, - ) - or page - ) - progress_function() - page_components.append(page_component) - - code = _compile_stateful_components(page_components)[0] if is_prod_mode() else "" - return output_path, code, page_components - - def purge_web_pages_dir(): """Empty out .web/pages directory.""" if not is_prod_mode() and environment.REFLEX_PERSIST_WEB_DIR.get(): @@ -869,59 +750,60 @@ def into_component(component: Component | ComponentCallable) -> Component: def compile_unevaluated_page( + route: str, page: UnevaluatedPage, - *, style: ComponentStyle | None = None, + theme: Component | None = None, ) -> Component: - """Compile an unevaluated page through the compiler plugin pipeline. - - This evaluates the page and applies the page compiler hooks before - returning the compiled root component. + """Compiles an uncompiled page into a component and adds meta information. Args: - page: The unevaluated page definition. - style: The app-level style map to apply. + route: The route of the page. + page: The uncompiled page object. + style: The style of the page. + theme: The theme of the page. Returns: - The compiled root component. + The compiled component and whether state should be enabled. + + Raises: + Exception: If an error occurs while evaluating the page. """ - hooks = CompilerHooks(plugins=default_page_plugins(style=style)) - compile_ctx = CompileContext(pages=[page], hooks=hooks) - - with compile_ctx: - page_ctx = hooks.eval_page( - page.component, - page=page, - compile_context=compile_ctx, - ) - if page_ctx is None: - page_name = getattr(page.component, "__name__", repr(page.component)) - msg = ( - f"No compiler plugin was able to evaluate page {page.route!r} " - f"({page_name})." - ) - raise RuntimeError(msg) + try: + # Generate the component if it is a callable. + component = into_component(page.component) - with page_ctx: - page_ctx.root_component = hooks.compile_component( - page_ctx.root_component, - page_context=page_ctx, - compile_context=compile_ctx, - ) - hooks.compile_page( - page_ctx, - page=page, - compile_context=compile_ctx, - ) + component._add_style_recursive(style or {}, theme) - if not isinstance(page_ctx.root_component, Component): - msg = ( - f"Compiled page {page.route!r} root must be a Component before it can " - "be returned." + from reflex_base.utils.format import make_default_page_title + + component = Fragment.create(component) + + meta_args = { + "title": ( + page.title + if page.title is not None + else make_default_page_title(get_config().app_name, route) + ), + "image": page.image, + "meta": page.meta, + } + + if page.description is not None: + meta_args["description"] = page.description + + # Add meta information to the component. + utils.add_meta( + component, + **meta_args, ) - raise TypeError(msg) - return page_ctx.root_component + except Exception as e: + if sys.version_info >= (3, 11): + e.add_note(f"Happened while evaluating page {route!r}") + raise + else: + return component def _resolve_app_wrap_components( @@ -1029,7 +911,9 @@ def compile_app( compile_ctx = CompileContext( app=app, pages=list(app._unevaluated_pages.values()), - hooks=CompilerHooks(plugins=default_page_plugins(style=app.style)), + hooks=CompilerHooks( + plugins=default_page_plugins(style=app.style, theme=app.theme) + ), ) with console.timing("Compile pages"), compile_ctx: @@ -1074,20 +958,15 @@ def compile_app( ] all_imports = compile_ctx.all_imports - if ( - code_uses_state_contexts(compile_ctx.stateful_components_code) - and app._state is None + if app._state is None and any( + code_uses_state_contexts(page_ctx.output_code or "") + for page_ctx in compile_ctx.compiled_pages.values() ): msg = ( "To access rx.State in frontend components, at least one " "subclass of rx.State must be defined in the app." ) raise ReflexRuntimeError(msg) - if compile_ctx.stateful_components_path is not None: - compile_results.append(( - compile_ctx.stateful_components_path, - compile_ctx.stateful_components_code, - )) progress.advance(task) app_wrappers = _resolve_app_wrap_components(app, compile_ctx.app_wrap_components) @@ -1100,7 +979,10 @@ def compile_app( memo_components_imports, ) = compile_memo_components( dict.fromkeys(CUSTOM_COMPONENTS.values()), - tuple(EXPERIMENTAL_MEMOS.values()), + ( + *tuple(EXPERIMENTAL_MEMOS.values()), + *tuple(compile_ctx.auto_memo_components.values()), + ), ) compile_results.append((memo_components_output, memo_components_result)) all_imports = utils.merge_imports(all_imports, memo_components_imports) diff --git a/reflex/compiler/plugins/__init__.py b/reflex/compiler/plugins/__init__.py index 2c641da4ed2..92e34115e3e 100644 --- a/reflex/compiler/plugins/__init__.py +++ b/reflex/compiler/plugins/__init__.py @@ -14,6 +14,7 @@ DefaultPagePlugin, default_page_plugins, ) +from .memoize import MemoizeStatefulPlugin __all__ = [ "ApplyStylePlugin", @@ -23,6 +24,7 @@ "ComponentAndChildren", "DefaultCollectorPlugin", "DefaultPagePlugin", + "MemoizeStatefulPlugin", "PageContext", "default_page_plugins", ] diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 62184e8817f..822352dc5dc 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -4,19 +4,11 @@ import dataclasses from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from reflex_base.components.component import ( - BaseComponent, - Component, - ComponentStyle, - StatefulComponent, -) -from reflex_base.config import get_config -from reflex_base.plugins import CompileContext, PageContext, Plugin +from typing import Any -if TYPE_CHECKING: - from reflex.app import UnevaluatedPage +from reflex_base.components.component import BaseComponent, Component, ComponentStyle +from reflex_base.config import get_config +from reflex_base.plugins import CompileContext, PageContext, PageDefinition, Plugin from reflex_base.utils.format import make_default_page_title from reflex_base.utils.imports import collapse_imports, merge_imports from reflex_base.vars import VarData @@ -34,7 +26,7 @@ def eval_page( page_fn: Any, /, *, - page: UnevaluatedPage, + page: PageDefinition, **kwargs: Any, ) -> PageContext: """Evaluate the page function and attach legacy page metadata. @@ -80,7 +72,9 @@ def eval_page( class ApplyStylePlugin(Plugin): """Apply app-level styles in the descending phase of the walk.""" + _compiler_can_replace_enter_component = False style: ComponentStyle | None = None + theme: Component | None = None @staticmethod def _apply_style(comp: Component, style: ComponentStyle) -> None: @@ -115,10 +109,9 @@ def enter_component( page_context: PageContext, compile_context: Any, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: """Apply the non-recursive portion of ``_add_style_recursive``.""" - del page_context, compile_context, stateful_component + del page_context, compile_context if self.style is not None and isinstance(comp, Component) and not in_prop_tree: self._apply_style(comp, self.style) @@ -127,7 +120,7 @@ def _compiler_bind_enter_component( self, page_context: PageContext, compile_context: CompileContext, - ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + ) -> Callable[[BaseComponent, bool], None]: """Bind a positional fast-path enter hook for style application. Returns: @@ -141,9 +134,8 @@ def _compiler_bind_enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> None: - del comp, in_prop_tree, stateful_component + del comp, in_prop_tree return enter_component @@ -152,10 +144,7 @@ def enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> None: - del stateful_component - if not isinstance(comp, Component) or in_prop_tree: return @@ -168,8 +157,8 @@ def enter_component( class DefaultCollectorPlugin(Plugin): """Collect page artifacts in one fused enter/leave hook pair.""" - _compiler_stateful_only_leave_component = True - stateful_custom_code_export: bool = False + _compiler_can_replace_enter_component = False + _compiler_can_replace_leave_component = False def enter_component( self, @@ -179,19 +168,10 @@ def enter_component( page_context: PageContext, compile_context: Any, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: """Collect imports and page artifacts for the active component node.""" del compile_context - if isinstance(comp, StatefulComponent): - if comp.rendered_as_shared: - self._extend_imports( - page_context.frontend_imports, - comp._get_all_imports(), - ) - return - if not isinstance(comp, Component): return @@ -199,14 +179,9 @@ def enter_component( imports = comp._get_imports() if imports: self._extend_imports(page_context.frontend_imports, imports) - self._collect_component_custom_code( - page_context.module_code, - comp, - stateful_custom_code_export=self.stateful_custom_code_export, - ) + self._collect_component_custom_code(page_context.module_code, comp) - if stateful_component is None: - self._collect_component_hooks(page_context.hooks, comp) + self._collect_component_hooks(page_context.hooks, comp) if ( type(comp)._get_app_wrap_components @@ -223,25 +198,6 @@ def enter_component( if (ref := comp.get_ref()) is not None: page_context.refs[ref] = None - def leave_component( - self, - comp: BaseComponent, - children: tuple[BaseComponent, ...], - /, - *, - page_context: PageContext, - compile_context: Any, - in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, - ) -> None: - """Collect post-traversal artifacts for stateful components.""" - del children, compile_context, in_prop_tree, stateful_component - - if isinstance(comp, StatefulComponent) and not comp.rendered_as_shared: - page_context.module_code[ - comp._render_stateful_code(export=self.stateful_custom_code_export) - ] = None - def compile_page( self, page_ctx: PageContext, @@ -270,7 +226,7 @@ def _compiler_bind_enter_component( self, page_context: PageContext, compile_context: CompileContext, - ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + ) -> Callable[[BaseComponent, bool], None]: """Bind a positional fast-path enter hook for artifact collection. Returns: @@ -284,7 +240,6 @@ def _compiler_bind_enter_component( dynamic_imports = page_context.dynamic_imports refs = page_context.refs app_wrap_components = page_context.app_wrap_components - stateful_custom_code_export = self.stateful_custom_code_export extend_imports = self._extend_imports collect_component_hooks = self._collect_component_hooks collect_component_custom_code = self._collect_component_custom_code @@ -295,13 +250,7 @@ def _compiler_bind_enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> None: - if isinstance(comp, StatefulComponent): - if comp.rendered_as_shared: - extend_imports(frontend_imports, comp._get_all_imports()) - return - if not isinstance(comp, Component): return @@ -309,14 +258,9 @@ def enter_component( imports_for_component = comp._get_imports() if imports_for_component: extend_imports(frontend_imports, imports_for_component) - collect_component_custom_code( - module_code, - comp, - stateful_custom_code_export=stateful_custom_code_export, - ) + collect_component_custom_code(module_code, comp) - if stateful_component is None: - collect_component_hooks(hooks, comp) + collect_component_hooks(hooks, comp) app_wrap_method = type(comp)._get_app_wrap_components if ( @@ -336,39 +280,6 @@ def enter_component( return enter_component - def _compiler_bind_leave_component( - self, - page_context: PageContext, - compile_context: CompileContext, - ) -> Callable[ - [BaseComponent, tuple[BaseComponent, ...], bool, StatefulComponent | None], - None, - ]: - """Bind a positional fast-path leave hook for stateful code emission. - - Returns: - A compiled leave hook that only takes hot-loop positional state. - """ - del compile_context - - module_code = page_context.module_code - stateful_custom_code_export = self.stateful_custom_code_export - - def leave_component( - comp: BaseComponent, - children: tuple[BaseComponent, ...], - in_prop_tree: bool, - stateful_component: StatefulComponent | None, - ) -> None: - del children, in_prop_tree, stateful_component - - if isinstance(comp, StatefulComponent) and not comp.rendered_as_shared: - module_code[ - comp._render_stateful_code(export=stateful_custom_code_export) - ] = None - - return leave_component - @staticmethod def _collect_component_hooks( page_hooks: dict[str, VarData | None], @@ -393,8 +304,6 @@ def _extend_imports( def _collect_component_custom_code( module_code: dict[str, None], component: Component, - *, - stateful_custom_code_export: bool, ) -> None: """Collect custom code for one structural-tree component in legacy order.""" if (custom_code := component._get_custom_code()) is not None: @@ -404,7 +313,6 @@ def _collect_component_custom_code( DefaultCollectorPlugin._collect_prop_custom_code_into( prop_component, module_code, - stateful_custom_code_export=stateful_custom_code_export, ) for clz in component._iter_parent_classes_with_method("add_custom_code"): @@ -415,24 +323,8 @@ def _collect_component_custom_code( def _collect_prop_custom_code_into( component: BaseComponent, module_code: dict[str, None], - *, - stateful_custom_code_export: bool, ) -> None: """Recursively collect prop-tree custom code directly into ``module_code``.""" - if isinstance(component, StatefulComponent): - if component.rendered_as_shared: - return - - DefaultCollectorPlugin._collect_prop_custom_code_into( - component.component, - module_code, - stateful_custom_code_export=stateful_custom_code_export, - ) - module_code[ - component._render_stateful_code(export=stateful_custom_code_export) - ] = None - return - if not isinstance(component, Component): module_code.update(component._get_all_custom_code()) return @@ -444,7 +336,6 @@ def _collect_prop_custom_code_into( DefaultCollectorPlugin._collect_prop_custom_code_into( prop_component, module_code, - stateful_custom_code_export=stateful_custom_code_export, ) for clz in component._iter_parent_classes_with_method("add_custom_code"): @@ -455,7 +346,6 @@ def _collect_prop_custom_code_into( DefaultCollectorPlugin._collect_prop_custom_code_into( child, module_code, - stateful_custom_code_export=stateful_custom_code_export, ) def _collect_app_wrap_components( @@ -518,15 +408,15 @@ def _collect_wrapper_subtree_into( def default_page_plugins( *, style: ComponentStyle | None = None, - stateful_custom_code_export: bool = False, + theme: Component | None = None, ) -> tuple[Plugin, ...]: """Return the default compiler plugin ordering for page compilation.""" + from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin + plugins: list[Plugin] = [DefaultPagePlugin()] if style is not None: - plugins.append(ApplyStylePlugin(style=style)) - plugins.append( - DefaultCollectorPlugin(stateful_custom_code_export=stateful_custom_code_export) - ) + plugins.append(ApplyStylePlugin(style=style, theme=theme)) + plugins.extend((MemoizeStatefulPlugin(), DefaultCollectorPlugin())) return tuple(plugins) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py new file mode 100644 index 00000000000..c2ee5337afa --- /dev/null +++ b/reflex/compiler/plugins/memoize.py @@ -0,0 +1,289 @@ +"""MemoizeStatefulPlugin — auto-memoize stateful components with ``rx._x.memo``. + +This plugin replaces the legacy ``StatefulComponent`` wrapping pass. It +participates in the normal single-pass walk via ``enter_component`` and inserts +per-subtree ``{children}``-pass-through wrappers built on the experimental +memo infrastructure. The wrapped subtree remains in the tree for the normal +walker descent, so downstream plugins (e.g. ``DefaultCollectorPlugin``) still +see the original components and collect their imports/hooks as usual. + +Each unique subtree shape contributes: + +- One generated experimental memo component definition, compiled into the + shared ``$/utils/components`` module. +- ``useCallback`` hook lines for each non-lifecycle event trigger, emitted into + ``page_context.hooks`` so the declarations live at the top of the page body. + +No shared ``stateful_components`` file is produced. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +from functools import cache +from typing import Any + +from reflex_base.components.component import ( + BaseComponent, + Component, + _deterministic_hash, + _hash_str, +) +from reflex_base.components.memoize_helpers import ( + fix_event_triggers_for_memo, + invalidate_event_trigger_caches, +) +from reflex_base.constants.compiler import MemoizationDisposition +from reflex_base.plugins import ComponentAndChildren, PageContext +from reflex_base.plugins.base import Plugin +from reflex_base.utils import format +from reflex_base.vars.base import Var + +from reflex.experimental.memo import create_passthrough_component_memo + +# --------------------------------------------------------------------------- # +# Tag naming + memoize-eligibility # +# --------------------------------------------------------------------------- # + + +def _child_var(child: Component) -> Var | Component: + """Return the core Var of a structural child, for memoize-eligibility checks. + + For special wrappers (``Bare``/``Cond``/``Foreach``/``Match``) we peek at + the contained Var instead of recursing into the wrapper component itself. + + Args: + child: The child component to inspect. + + Returns: + The contained Var if ``child`` is a special wrapper, else ``child``. + """ + from reflex_components_core.base.bare import Bare + from reflex_components_core.core.cond import Cond + from reflex_components_core.core.foreach import Foreach + from reflex_components_core.core.match import Match + + if isinstance(child, Bare): + return child.contents + if isinstance(child, Cond): + return child.cond + if isinstance(child, Foreach): + return child.iterable + if isinstance(child, Match): + return child.cond + return child + + +def _compute_memo_tag(component: Component) -> str | None: + """Compute a stable tag name for a memoizable component. + + Returns ``None`` for components that render empty (non-visual components + are never memoized). + + Args: + component: The component to name. + + Returns: + The stable tag name, or ``None`` if the component renders empty. + """ + rendered_code = component.render() + if not rendered_code: + return None + code_hash = _hash_str(_deterministic_hash(rendered_code)) + return format.format_state_name( + f"{component.tag or 'Comp'}_{code_hash}" + ).capitalize() + + +def _should_memoize(component: Component) -> bool: + """Decide whether ``component`` is a candidate for auto-memoization. + + Checks for DIRECT triggers only (not walking into descendants): the + component's own Vars with var_data, event_triggers, or special child + types (Bare/Cond/Foreach/Match) whose probe Var carries var_data. + + Args: + component: The candidate component. + + Returns: + True if the component should be wrapped in a memo definition. + """ + from reflex_components_core.core.foreach import Foreach + + if component._memoization_mode.disposition == MemoizationDisposition.NEVER: + return False + if component.tag is None: + return False + if component._memoization_mode.disposition == MemoizationDisposition.ALWAYS: + return True + + # Direct Vars only (component's own props, style, class_name, id, etc.). + for prop_var in component._get_vars(include_children=False): + if prop_var._get_all_var_data(): + return True + + # Special-case structural children that are Var wrappers (Bare/Cond/ + # Foreach/Match). Foreach is always memoized because it produces dynamic + # child trees that React must reconcile by key. + for child in component.children: + if not isinstance(child, Component): + continue + if isinstance(child, Foreach): + return True + probe = _child_var(child) + if isinstance(probe, Var) and probe._get_all_var_data(): + return True + + # Components with event triggers are always memoized (to wrap callbacks). + return bool(component.event_triggers) + + +@cache +def _get_passthrough_memo_component(tag: str) -> tuple[Any, Any]: + """Return the generated experimental memo wrapper callable and definition. + + Args: + tag: The wrapper's exported component name. + + Returns: + The memo wrapper callable and its definition. + """ + return create_passthrough_component_memo(tag) + + +# --------------------------------------------------------------------------- # +# The plugin # +# --------------------------------------------------------------------------- # + + +@dataclasses.dataclass(frozen=True, slots=True) +class MemoizeStatefulPlugin(Plugin): + """Auto-memoize stateful components with ``{children}``-pass-through memos. + + Registered in ``default_page_plugins`` between ``ApplyStylePlugin`` and + ``DefaultCollectorPlugin``. On ``enter_component`` it decides whether a + component should be memoized, and if so wraps it in a generated + experimental memo component whose single child is the original. The walker + then descends into the original component normally so + ``DefaultCollectorPlugin`` still sees its subtree. + + A ``_memoize_wrapped`` attribute marks the original component so the + recursive visit doesn't re-wrap it. + """ + + _compiler_can_replace_enter_component = True + _compiler_can_replace_leave_component = False + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + ) -> BaseComponent | ComponentAndChildren | None: + """Wrap eligible stateful components in an experimental memo component. + + Args: + comp: The component being visited. + page_context: The active page context. + compile_context: The active compile context. + in_prop_tree: Whether the component is in a prop subtree. + + Returns: + A ``(wrapper, (comp,))`` tuple replacement when ``comp`` is + memoizable, else ``None``. + """ + if in_prop_tree: + return None + if not isinstance(comp, Component): + return None + + # Re-entry guard: when the walker descends into our wrapped child, it + # calls enter_component on the original comp again. Clear the marker + # and pass through. + if getattr(comp, "_memoize_wrapped", False): + with contextlib.suppress(AttributeError): + del comp._memoize_wrapped # pyright: ignore[reportAttributeAccessIssue] + return None + + # Inside a MemoizationLeaf subtree, do not independently wrap + # descendants (the leaf owns the wrapping decision for its subtree). + if getattr(page_context, "_memoize_suppress_depth", 0) > 0: + return None + + is_memoization_leaf = not comp._memoization_mode.recursive + + if not _should_memoize(comp): + if is_memoization_leaf: + # Leaf that wasn't memoized still suppresses descendants. + page_context._memoize_suppress_depth = ( # type: ignore[attr-defined] + getattr(page_context, "_memoize_suppress_depth", 0) + 1 + ) + comp._memoize_pushed_suppression = True # type: ignore[attr-defined] + return None + + tag = _compute_memo_tag(comp) + if tag is None: + return None + + # Memoize event triggers, collect useCallback hooks for the page body. + memo_trigger_hooks = fix_event_triggers_for_memo(comp) + if memo_trigger_hooks: + invalidate_event_trigger_caches(comp) + for hook in memo_trigger_hooks: + page_context.hooks[hook] = None + + compile_context.memoize_wrappers[tag] = None + wrapper_factory, definition = _get_passthrough_memo_component(tag) + compile_context.auto_memo_components[tag] = definition + + # If comp is a MemoizationLeaf that IS being wrapped, suppress + # descendant wrapping for its subtree. + if is_memoization_leaf: + page_context._memoize_suppress_depth = ( # type: ignore[attr-defined] + getattr(page_context, "_memoize_suppress_depth", 0) + 1 + ) + comp._memoize_pushed_suppression = True # type: ignore[attr-defined] + + # Mark the original so the recursive re-enter skips wrapping. + comp._memoize_wrapped = True # type: ignore[attr-defined] + + wrapper = wrapper_factory(comp) + return (wrapper, (comp,)) + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + ) -> BaseComponent | ComponentAndChildren | None: + """Pop the ``MemoizationLeaf`` suppression counter if we pushed one. + + Args: + comp: The component being visited. + children: Its compiled children (unused). + page_context: The active page context. + compile_context: The active compile context (unused). + in_prop_tree: Whether the component is in a prop subtree (unused). + + Returns: + Always ``None``. + """ + del children, compile_context, in_prop_tree + if getattr(comp, "_memoize_pushed_suppression", False): + page_context._memoize_suppress_depth = ( # type: ignore[attr-defined] + getattr(page_context, "_memoize_suppress_depth", 1) - 1 + ) + with contextlib.suppress(AttributeError): + del comp._memoize_pushed_suppression # pyright: ignore[reportAttributeAccessIssue] + return None + + +__all__ = ["MemoizeStatefulPlugin"] diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index bb812c67c5f..3dad8c3cd1f 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -656,19 +656,6 @@ def get_components_path() -> str: ) -def get_stateful_components_path() -> str: - """Get the path of the compiled stateful components. - - Returns: - The path of the compiled stateful components. - """ - return str( - get_web_dir() - / constants.Dirs.UTILS - / (constants.PageNames.STATEFUL_COMPONENTS + constants.Ext.JSX) - ) - - def add_meta( page: Component, title: str, diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 7dee0c72eea..a5f321a900b 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -950,6 +950,40 @@ def _create_component_wrapper( return _ExperimentalMemoComponentWrapper(definition) +@cache +def create_passthrough_component_memo( + export_name: str, +) -> tuple[ + Callable[..., ExperimentalMemoComponent], + ExperimentalMemoComponentDefinition, +]: + """Create an unregistered ``@rx._x.memo``-style passthrough component memo. + + This is used by compiler auto-memoization so generated wrappers compile + through the experimental memo pipeline instead of emitting ad-hoc page-local + ``React.memo`` declarations. + + Args: + export_name: The exported memo component name. + + Returns: + The callable memo wrapper and its component definition. + """ + + def passthrough(children: Var[Component]) -> Component: + return Bare.create(children) + + passthrough.__name__ = format.to_snake_case(export_name) + passthrough.__qualname__ = passthrough.__name__ + passthrough.__module__ = __name__ + + definition = _create_component_definition(passthrough, Component) + if definition.export_name != export_name: + definition = dataclasses.replace(definition, export_name=export_name) + + return _create_component_wrapper(definition), definition + + def memo(fn: Callable[..., Any]) -> Callable[..., Any]: """Create an experimental memo from a function. @@ -986,3 +1020,14 @@ def memo(fn: Callable[..., Any]) -> Callable[..., Any]: f"got `{return_annotation}`." ) raise TypeError(msg) + + +__all__ = [ + "EXPERIMENTAL_MEMOS", + "ExperimentalMemoComponent", + "ExperimentalMemoComponentDefinition", + "ExperimentalMemoDefinition", + "ExperimentalMemoFunctionDefinition", + "create_passthrough_component_memo", + "memo", +] diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index 9436b0bfae7..63469330109 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -4,7 +4,7 @@ import pytest from pydantic import BaseModel -from reflex_base.components.component import BaseComponent, Component, StatefulComponent +from reflex_base.components.component import BaseComponent, Component from reflex_base.plugins import CompileContext, PageContext import reflex as rx @@ -252,7 +252,7 @@ def _compiler_bind_enter_component( self, page_context: PageContext, compile_context: CompileContext, - ) -> Callable[[BaseComponent, bool, StatefulComponent | None], None]: + ) -> Callable[[BaseComponent, bool], None]: del compile_context frontend_imports = page_context.frontend_imports @@ -261,15 +261,7 @@ def _compiler_bind_enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - stateful_component: StatefulComponent | None, ) -> None: - del stateful_component - - if isinstance(comp, StatefulComponent): - if comp.rendered_as_shared: - extend_imports(frontend_imports, comp._get_all_imports()) - return - if not isinstance(comp, Component) or in_prop_tree: return diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index 69ef9bb045f..f9b1f134e5b 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -1,11 +1,14 @@ +import copy + from pytest_codspeed import BenchmarkFixture -from reflex_base.components.component import Component, StatefulComponent +from reflex_base.components.component import Component from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex.app import UnevaluatedPage from reflex.compiler import compiler -from reflex.compiler.compiler import _compile_page, _compile_stateful_components +from reflex.compiler.compiler import _compile_page from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins +from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin from .fixtures import ImportOnlyCollectorPlugin @@ -16,12 +19,17 @@ def import_templates(): def _compile_single_pass_page_ctx(component: Component) -> PageContext: + # The single-pass compiler mutates the tree in place when it inserts memo + # wrappers, so benchmark iterations need an isolated copy of the input. + component = copy.deepcopy(component) page_ctx = PageContext( name="benchmark", route="/benchmark", - root_component=StatefulComponent.compile_from(component) or component, + root_component=component, + ) + hooks = CompilerHooks( + plugins=(MemoizeStatefulPlugin(), DefaultCollectorPlugin()), ) - hooks = CompilerHooks(plugins=(DefaultCollectorPlugin(),)) compile_ctx = CompileContext(pages=[], hooks=hooks) with compile_ctx, page_ctx: @@ -107,12 +115,6 @@ def test_compile_page_full_context( benchmark(lambda: _compile_page_full_context(unevaluated_page)) -def test_compile_stateful(evaluated_page: Component, benchmark: BenchmarkFixture): - import_templates() - - benchmark(lambda: _compile_stateful_components([evaluated_page])) - - def test_get_all_imports(evaluated_page: Component, benchmark: BenchmarkFixture): benchmark(lambda: evaluated_page._get_all_imports()) diff --git a/tests/integration/test_auto_memo.py b/tests/integration/test_auto_memo.py new file mode 100644 index 00000000000..2f6e8a0daa0 --- /dev/null +++ b/tests/integration/test_auto_memo.py @@ -0,0 +1,73 @@ +"""Integration tests for compiler-generated experimental memos.""" + +from collections.abc import Generator + +import pytest +from selenium.webdriver.common.by import By + +from reflex.testing import AppHarness + +from .utils import poll_for_navigation + + +def AutoMemoAcrossPagesApp(): + """Reflex app that shares one stateful subtree across two pages.""" + import reflex as rx + + def shared_counter() -> rx.Component: + return rx.text(rx.State.router.path, id="shared-value") + + def index() -> rx.Component: + return rx.vstack( + shared_counter(), + rx.link("Other", href="/other", id="to-other"), + ) + + def other() -> rx.Component: + return rx.vstack( + shared_counter(), + rx.link("Home", href="/", id="to-home"), + ) + + app = rx.App() + app.add_page(index) + app.add_page(other, route="/other") + + +@pytest.fixture +def auto_memo_app(tmp_path) -> Generator[AppHarness, None, None]: + """Start AutoMemoAcrossPagesApp app at tmp_path via AppHarness. + + Yields: + A running AppHarness instance. + """ + with AppHarness.create( + root=tmp_path, + app_source=AutoMemoAcrossPagesApp, + ) as harness: + yield harness + + +def test_auto_memo_shared_across_pages(auto_memo_app: AppHarness): + """Shared stateful subtrees compile once and render correctly on both pages.""" + assert auto_memo_app.app_instance is not None, "app is not running" + + web_sources = "\n".join( + path.read_text() for path in (auto_memo_app.app_path / ".web").rglob("*.jsx") + ) + assert "$/utils/components" in web_sources + assert "$/utils/stateful_components" not in web_sources + + driver = auto_memo_app.frontend() + shared_value = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "shared-value") + ) + assert auto_memo_app.poll_for_content(shared_value, exp_not_equal="") == "/" + + with poll_for_navigation(driver): + driver.find_element(By.ID, "to-other").click() + + shared_value = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "shared-value") + ) + assert "other" in auto_memo_app.poll_for_content(shared_value, exp_not_equal="") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py new file mode 100644 index 00000000000..50d0a5d50e1 --- /dev/null +++ b/tests/units/compiler/test_memoize_plugin.py @@ -0,0 +1,216 @@ +# ruff: noqa: D101 + +import dataclasses +from collections.abc import Callable +from typing import Any + +from reflex_base.components.component import Component, field +from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode +from reflex_base.plugins import CompileContext, CompilerHooks, PageContext +from reflex_base.vars import VarData +from reflex_base.vars.base import LiteralVar, Var +from reflex_components_core.base.fragment import Fragment + +from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins +from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin, _should_memoize +from reflex.experimental.memo import ( + ExperimentalMemoComponent, + create_passthrough_component_memo, +) + +STATE_VAR = LiteralVar.create("value")._replace( + merge_var_data=VarData(hooks={"useTestState": None}, state="TestState") +) + + +class Plain(Component): + tag = "Plain" + library = "plain-lib" + + +class WithProp(Component): + tag = "WithProp" + library = "with-prop-lib" + + label: Var[str] = field(default=LiteralVar.create("")) + + +class LeafComponent(Component): + tag = "LeafComponent" + library = "leaf-lib" + _memoization_mode = MemoizationMode(recursive=False) + + +@dataclasses.dataclass(slots=True) +class FakePage: + route: str + component: Callable[[], Component] + title: Any = None + description: Any = None + image: str = "" + meta: tuple[dict[str, Any], ...] = () + + +def _compile_single_page( + component_factory: Callable[[], Component], +) -> tuple[CompileContext, PageContext]: + ctx = CompileContext( + pages=[FakePage(route="/p", component=component_factory)], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx: + ctx.compile() + return ctx, ctx.compiled_pages["/p"] + + +def test_should_memoize_catches_direct_state_var_in_prop() -> None: + """A component whose own prop carries state VarData should memoize.""" + comp = WithProp.create(label=STATE_VAR) + assert _should_memoize(comp) + + +def test_should_memoize_catches_state_var_in_child_bare() -> None: + """A component whose Bare child contains state VarData should memoize.""" + comp = Plain.create(STATE_VAR) + assert _should_memoize(comp) + + +def test_should_not_memoize_plain_component() -> None: + """A component with no state vars and no event triggers is not memoized.""" + comp = Plain.create(LiteralVar.create("static-content")) + assert not _should_memoize(comp) + + +def test_should_not_memoize_when_disposition_never() -> None: + """``MemoizationDisposition.NEVER`` overrides heuristic eligibility.""" + comp = Plain.create(STATE_VAR) + object.__setattr__( + comp, + "_memoization_mode", + dataclasses.replace( + comp._memoization_mode, disposition=MemoizationDisposition.NEVER + ), + ) + assert not _should_memoize(comp) + + +def test_memoize_wrapper_uses_experimental_memo_component_and_call_site() -> None: + """Memoizable component imports a generated ``rx._x.memo`` wrapper.""" + ctx, page_ctx = _compile_single_page(lambda: Plain.create(STATE_VAR)) + + assert len(ctx.memoize_wrappers) == 1 + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert wrapper_tag in ctx.auto_memo_components + output = page_ctx.output_code or "" + assert f'import {{{wrapper_tag}}} from "$/utils/components"' in output + assert f"jsx({wrapper_tag}," in (page_ctx.output_code or "") + assert f"const {wrapper_tag} = memo" not in output + + +def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None: + """Two identical memoizable call-sites collapse to one memo definition.""" + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create( + Plain.create(STATE_VAR), + Plain.create(STATE_VAR), + ) + ) + assert len(ctx.memoize_wrappers) == 1 + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert list(ctx.auto_memo_components) == [wrapper_tag] + assert (page_ctx.output_code or "").count( + f'import {{{wrapper_tag}}} from "$/utils/components"' + ) == 1 + + +def test_memoization_leaf_suppresses_descendant_wrapping() -> None: + """A MemoizationLeaf suppresses independent wrappers for its descendants. + + Even when a descendant (``Plain(STATE_VAR)``) would otherwise be wrapped, + being inside a leaf's subtree suppresses that wrapping. Whether or not the + leaf itself gets wrapped, descendants do not produce their own wrappers. + """ + ctx, _page_ctx = _compile_single_page( + lambda: LeafComponent.create( + Plain.create(STATE_VAR), # would otherwise be independently memoized + ) + ) + # The inner Plain(STATE_VAR) is suppressed because it's inside the leaf's + # subtree. The leaf itself has no direct state dependency so no wrapper + # is emitted for it either. + assert len(ctx.memoize_wrappers) == 0 + + +def test_generated_memo_component_is_not_itself_memoized() -> None: + """The generated memo component instance itself is skipped by the heuristic.""" + wrapper_factory, _definition = create_passthrough_component_memo("MyTag") + wrapper = wrapper_factory(Plain.create()) + assert isinstance(wrapper, ExperimentalMemoComponent) + assert not _should_memoize(wrapper) + + +def test_event_trigger_memoization_emits_usecallback_in_page_hooks() -> None: + """Components with event triggers get useCallback wrappers at the page level.""" + from reflex_base.event import EventChain + + # Construct an event chain referencing state so _get_memoized_event_triggers + # emits a useCallback. + event_var = Var(_js_expr="test_event")._replace( + _var_type=EventChain, + merge_var_data=VarData(state="TestState"), + ) + comp = Plain.create() + comp.event_triggers["on_click"] = event_var + + _ctx, page_ctx = _compile_single_page(lambda: comp) + + # Check that a useCallback hook line was added to the page hooks dict. + hook_lines = list(page_ctx.hooks.keys()) + assert any( + "useCallback" in hook_line and "on_click_" in hook_line + for hook_line in hook_lines + ), f"Expected on_click useCallback hook in {hook_lines!r}" + + +def test_generated_memo_component_renders_as_its_exported_tag() -> None: + """The generated experimental memo component renders as its exported tag.""" + wrapper_factory, definition = create_passthrough_component_memo("MyWrapper_abc") + wrapper = wrapper_factory(Plain.create()) + assert isinstance(wrapper, ExperimentalMemoComponent) + assert wrapper.tag == "MyWrapper_abc" + assert definition.export_name == "MyWrapper_abc" + assert wrapper.render()["name"] == "MyWrapper_abc" + + +def test_shared_subtree_across_pages_uses_same_tag() -> None: + """The same memoizable subtree on multiple pages gets one shared tag.""" + ctx = CompileContext( + pages=[ + FakePage(route="/a", component=lambda: Plain.create(STATE_VAR)), + FakePage(route="/b", component=lambda: Plain.create(STATE_VAR)), + ], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx: + ctx.compile() + + assert len(ctx.memoize_wrappers) == 1 + tag = next(iter(ctx.memoize_wrappers)) + assert list(ctx.auto_memo_components) == [tag] + for route in ("/a", "/b"): + output = ctx.compiled_pages[route].output_code or "" + assert f'import {{{tag}}} from "$/utils/components"' in output + assert f"jsx({tag}," in output + + +def test_plugin_only_registered_once_in_default_page_plugins() -> None: + """MemoizeStatefulPlugin appears exactly once in the default plugin pipeline.""" + plugins = default_page_plugins() + memoize_plugins = [p for p in plugins if isinstance(p, MemoizeStatefulPlugin)] + assert len(memoize_plugins) == 1 + # And it is registered before the DefaultCollectorPlugin. + collector_index = next( + i for i, p in enumerate(plugins) if isinstance(p, DefaultCollectorPlugin) + ) + memoize_index = plugins.index(memoize_plugins[0]) + assert memoize_index < collector_index diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 7a319bd3743..e406c300818 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -1,24 +1,23 @@ # ruff: noqa: D101, D102 import dataclasses +from collections.abc import Callable from typing import Any import pytest -from reflex_base import constants from reflex_base.components.component import ( BaseComponent, Component, ComponentStyle, - StatefulComponent, field, ) -from reflex_base.environment import environment from reflex_base.plugins import ( BaseContext, CompileContext, CompilerHooks, ComponentAndChildren, PageContext, + PageDefinition, Plugin, ) from reflex_base.utils import format as format_utils @@ -38,9 +37,18 @@ @dataclasses.dataclass(slots=True) +class FakePage: + route: str + component: Callable[[], Component] + title: Var | str | None = None + description: Var | str | None = None + image: str = "" + meta: tuple[dict[str, Any], ...] = () + + class WrapperComponent(Component): - tag: str | None = "WrapperComponent" - library: str | None = "wrapper-lib" + tag = "WrapperComponent" + library = "wrapper-lib" @staticmethod def _get_app_wrap_components() -> dict[tuple[int, str], Component]: @@ -102,11 +110,6 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]: return {(15, "PropWrap"): Fragment.create()} -class NoRecursiveImportsComponent(Component): - tag = "NoRecursiveImportsComponent" - library = "no-recursive-imports-lib" - - class SharedLibraryComponent(Component): tag = "SharedLibraryComponent" library = "react-moment" @@ -116,7 +119,12 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]: return {(25, "SharedLibraryWrap"): Fragment.create()} -class StubCompilerPlugin(Plugin): +class InlineStatefulComponent(Component): + tag = "InlineStatefulComponent" + library = "inline-lib" + + +class StubPlugin(Plugin): pass @@ -127,6 +135,13 @@ class StubCompilerPlugin(Plugin): ) ) +INLINE_STATEFUL_VAR = LiteralVar.create("inline")._replace( + merge_var_data=VarData( + hooks={"useInlineStatefulValue": None}, + state="InlineState", + ) +) + def create_component_tree() -> RootComponent: return RootComponent.create( @@ -140,8 +155,8 @@ def create_shared_stateful_component() -> SharedLibraryComponent: return SharedLibraryComponent.create(SHARED_STATEFUL_VAR) -def create_no_recursive_imports_component() -> NoRecursiveImportsComponent: - return NoRecursiveImportsComponent.create() +def create_inline_stateful_component() -> InlineStatefulComponent: + return InlineStatefulComponent.create(INLINE_STATEFUL_VAR) def page_style() -> ComponentStyle: @@ -187,27 +202,27 @@ def collect_page_context( def test_eval_page_uses_first_non_none_result() -> None: calls: list[str] = [] - page = UnevaluatedPage(route="/demo", component=lambda: Fragment.create()) + page = FakePage(route="/demo", component=lambda: Fragment.create()) - class NoMatchPlugin(StubCompilerPlugin): + class NoMatchPlugin(StubPlugin): def eval_page( self, page_fn: Any, /, *, - page: UnevaluatedPage, + page: PageDefinition, **kwargs: Any, ) -> None: del page_fn, page, kwargs calls.append("no-match") - class MatchPlugin(StubCompilerPlugin): + class MatchPlugin(StubPlugin): def eval_page( self, page_fn: Any, /, *, - page: UnevaluatedPage, + page: PageDefinition, **kwargs: Any, ) -> PageContext: del kwargs @@ -218,13 +233,13 @@ def eval_page( root_component=page_fn(), ) - class UnreachablePlugin(StubCompilerPlugin): + class UnreachablePlugin(StubPlugin): def eval_page( self, page_fn: Any, /, *, - page: UnevaluatedPage, + page: PageDefinition, **kwargs: Any, ) -> PageContext: del page_fn, page, kwargs @@ -249,7 +264,7 @@ def test_compile_page_runs_plugins_in_registration_order() -> None: root_component=Fragment.create(), ) - class FirstPlugin(StubCompilerPlugin): + class FirstPlugin(StubPlugin): def compile_page( self, page_ctx: PageContext, @@ -259,7 +274,7 @@ def compile_page( del page_ctx, kwargs calls.append("first") - class SecondPlugin(StubCompilerPlugin): + class SecondPlugin(StubPlugin): def compile_page( self, page_ctx: PageContext, @@ -276,7 +291,7 @@ def compile_page( def test_component_hook_resolution_caches_only_real_overrides() -> None: - class EnterPlugin(StubCompilerPlugin): + class EnterPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -285,11 +300,10 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del comp, page_context, compile_context, in_prop_tree, stateful_component + del comp, page_context, compile_context, in_prop_tree - class LeavePlugin(StubCompilerPlugin): + class LeavePlugin(StubPlugin): def leave_component( self, comp: BaseComponent, @@ -299,7 +313,6 @@ def leave_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: del ( comp, @@ -307,7 +320,6 @@ def leave_component( page_context, compile_context, in_prop_tree, - stateful_component, ) hooks = CompilerHooks(plugins=(Plugin(), EnterPlugin(), LeavePlugin())) @@ -330,15 +342,14 @@ def fail_enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del self, comp, page_context, compile_context, in_prop_tree, stateful_component + del self, comp, page_context, compile_context, in_prop_tree msg = "Inherited Plugin.enter_component hook should be skipped." raise AssertionError(msg) monkeypatch.setattr(Plugin, "enter_component", fail_enter_component) - class RealPlugin(StubCompilerPlugin): + class RealPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -347,9 +358,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del page_context, compile_context, in_prop_tree, stateful_component + del page_context, compile_context, in_prop_tree visited.append(type(comp).__name__) hooks = CompilerHooks(plugins=(Plugin(), RealPlugin())) @@ -380,9 +390,8 @@ def fail_enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del self, comp, page_context, compile_context, in_prop_tree, stateful_component + del self, comp, page_context, compile_context, in_prop_tree msg = "Inherited Plugin.enter_component hook should be skipped." raise AssertionError(msg) @@ -391,7 +400,7 @@ def fail_enter_component( class ProtocolOnlyPlugin(Plugin): pass - class RealPlugin(StubCompilerPlugin): + class RealPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -400,9 +409,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del page_context, compile_context, in_prop_tree, stateful_component + del page_context, compile_context, in_prop_tree visited.append(type(comp).__name__) hooks = CompilerHooks(plugins=(ProtocolOnlyPlugin(), RealPlugin())) @@ -423,7 +431,7 @@ def test_compile_component_orders_enter_and_leave_by_plugin() -> None: events: list[str] = [] root = RootComponent.create() - class FirstPlugin(StubCompilerPlugin): + class FirstPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -432,9 +440,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del comp, page_context, compile_context, in_prop_tree, stateful_component + del comp, page_context, compile_context, in_prop_tree events.append("first:enter") def leave_component( @@ -446,7 +453,6 @@ def leave_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: del ( comp, @@ -454,11 +460,10 @@ def leave_component( page_context, compile_context, in_prop_tree, - stateful_component, ) events.append("first:leave") - class SecondPlugin(StubCompilerPlugin): + class SecondPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -467,9 +472,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del comp, page_context, compile_context, in_prop_tree, stateful_component + del comp, page_context, compile_context, in_prop_tree events.append("second:enter") def leave_component( @@ -481,7 +485,6 @@ def leave_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: del ( comp, @@ -489,7 +492,6 @@ def leave_component( page_context, compile_context, in_prop_tree, - stateful_component, ) events.append("second:leave") @@ -520,7 +522,7 @@ def test_compile_component_traverses_children_before_prop_components() -> None: slot=PropComponent.create(), ) - class VisitPlugin(StubCompilerPlugin): + class VisitPlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -529,9 +531,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> None: - del page_context, compile_context, in_prop_tree, stateful_component + del page_context, compile_context, in_prop_tree if isinstance(comp, Component): visited.append(comp.tag or type(comp).__name__) @@ -553,7 +554,7 @@ def test_enter_and_leave_replacements_match_generator_style_behavior() -> None: child = ChildComponent.create(id="original") root = RootComponent.create(child) - class ReplacePlugin(StubCompilerPlugin): + class ReplacePlugin(StubPlugin): def enter_component( self, comp: BaseComponent, @@ -562,9 +563,8 @@ def enter_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> BaseComponent | ComponentAndChildren | None: - del page_context, compile_context, stateful_component + del page_context, compile_context if isinstance(comp, RootComponent) and not in_prop_tree: replacement_child = ChildComponent.create(id="replacement") return comp, (replacement_child,) @@ -579,9 +579,8 @@ def leave_component( page_context: PageContext, compile_context: CompileContext, in_prop_tree: bool = False, - stateful_component: StatefulComponent | None = None, ) -> BaseComponent | ComponentAndChildren | None: - del page_context, compile_context, in_prop_tree, stateful_component + del page_context, compile_context, in_prop_tree if isinstance(comp, RootComponent): return Fragment.create(comp), children return None @@ -752,16 +751,19 @@ def test_default_collector_matches_legacy_collectors() -> None: def test_default_page_plugins_are_minimal_and_ordered() -> None: + from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin + plugins = default_page_plugins(style=page_style()) - assert len(plugins) == 3 + assert len(plugins) == 4 assert isinstance(plugins[0], DefaultPagePlugin) assert isinstance(plugins[1], ApplyStylePlugin) - assert isinstance(plugins[2], DefaultCollectorPlugin) + assert isinstance(plugins[2], MemoizeStatefulPlugin) + assert isinstance(plugins[3], DefaultCollectorPlugin) -def test_compile_context_compiles_pages_and_matches_direct_page_compile() -> None: - page = UnevaluatedPage(route="/demo", component=create_component_tree) +def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: + page = FakePage(route="/demo", component=create_component_tree) compile_ctx = CompileContext( pages=[page], hooks=CompilerHooks(plugins=default_page_plugins(style=page_style())), @@ -796,36 +798,26 @@ def test_compile_context_compiles_pages_and_matches_direct_page_compile() -> Non == page_ctx.root_component._get_all_app_wrap_components().keys() ) - expected_component = compiler.compile_unevaluated_page( - page, - style=page_style(), + legacy_component = compiler.compile_unevaluated_page( + page.route, + UnevaluatedPage( + component=page.component, + route=page.route, + title=page.title, + description=page.description, + image=page.image, + on_load=None, + meta=page.meta, + context={}, + ), + page_style(), + None, ) - expected_output = compiler.compile_page(page.route, expected_component)[1] + expected_output = compiler.compile_page(page.route, legacy_component)[1] assert page_ctx.output_code == expected_output -def test_compile_context_does_not_recurse_root_imports() -> None: - page = UnevaluatedPage( - route="/no-recursive-imports", - component=create_no_recursive_imports_component, - ) - compile_ctx = CompileContext( - pages=[page], - hooks=CompilerHooks(plugins=default_page_plugins()), - ) - - with compile_ctx: - compiled_pages = compile_ctx.compile() - - page_ctx = compiled_pages["/no-recursive-imports"] - assert "no-recursive-imports-lib" in page_ctx.frontend_imports - assert "no-recursive-imports-lib" in compile_ctx.all_imports - assert page_ctx.output_code is not None - - -def test_default_page_plugin_handles_var_backed_title_like_direct_page_compile() -> ( - None -): +def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> None: page = UnevaluatedPage( component=lambda: Fragment.create(), route="/var-title", @@ -848,14 +840,19 @@ def test_default_page_plugin_handles_var_backed_title_like_direct_page_compile() assert page_ctx is not None - expected_component = compiler.compile_unevaluated_page(page) - assert page_ctx.root_component.render() == expected_component.render() + legacy_component = compiler.compile_unevaluated_page( + page.route, + page, + None, + None, + ) + assert page_ctx.root_component.render() == legacy_component.render() def test_compile_context_rejects_duplicate_routes() -> None: pages = [ - UnevaluatedPage(route="/duplicate", component=lambda: Fragment.create()), - UnevaluatedPage(route="/duplicate", component=lambda: Fragment.create()), + FakePage(route="/duplicate", component=lambda: Fragment.create()), + FakePage(route="/duplicate", component=lambda: Fragment.create()), ] compile_ctx = CompileContext( pages=pages, @@ -884,60 +881,103 @@ def test_compile_context_requires_attached_context() -> None: compile_ctx.compile() -def test_compile_context_preserves_shared_stateful_component_imports_and_wraps() -> ( - None -): - previous_mode = environment.REFLEX_ENV_MODE.get() - environment.REFLEX_ENV_MODE.set(constants.Env.PROD) - try: - pages = [ - UnevaluatedPage(route="/a", component=create_shared_stateful_component), - UnevaluatedPage(route="/b", component=create_shared_stateful_component), - ] - compile_ctx = CompileContext( - pages=pages, - hooks=CompilerHooks(plugins=default_page_plugins()), - ) +def test_compile_context_memoize_wrappers_registers_shared_subtree_tag() -> None: + """Shared memoizable subtree across pages registers a single wrapper tag.""" + pages = [ + FakePage(route="/a", component=create_shared_stateful_component), + FakePage(route="/b", component=create_shared_stateful_component), + ] + compile_ctx = CompileContext( + pages=pages, + hooks=CompilerHooks(plugins=default_page_plugins()), + ) - with compile_ctx: - compile_ctx.compile() + with compile_ctx: + compile_ctx.compile() - assert "react-moment" in compile_ctx.all_imports - assert (25, "SharedLibraryWrap") in compile_ctx.app_wrap_components - assert "react-moment" in compile_ctx.stateful_components_code - assert "$/utils/stateful_components" in ( - compile_ctx.compiled_pages["/a"].output_code or "" - ) - finally: - environment.REFLEX_ENV_MODE.set(previous_mode) - - -def test_compile_context_resets_stateful_component_cache_between_runs() -> None: - previous_mode = environment.REFLEX_ENV_MODE.get() - try: - environment.REFLEX_ENV_MODE.set(constants.Env.PROD) - prod_ctx = CompileContext( - pages=[ - UnevaluatedPage(route="/a", component=create_shared_stateful_component), - UnevaluatedPage(route="/b", component=create_shared_stateful_component), - ], - hooks=CompilerHooks(plugins=default_page_plugins()), - ) - with prod_ctx: - prod_ctx.compile() - - environment.REFLEX_ENV_MODE.set(constants.Env.DEV) - dev_ctx = CompileContext( - pages=[ - UnevaluatedPage(route="/c", component=create_shared_stateful_component) - ], - hooks=CompilerHooks(plugins=default_page_plugins()), - ) - with dev_ctx: - dev_ctx.compile() - - page_ctx = dev_ctx.compiled_pages["/c"] - assert "react-moment" in page_ctx.frontend_imports - assert "$/utils/stateful_components" not in (page_ctx.output_code or "") - finally: - environment.REFLEX_ENV_MODE.set(previous_mode) + # The wrapped library import still reaches the compile-context level. + assert "react-moment" in compile_ctx.all_imports + assert (25, "SharedLibraryWrap") in compile_ctx.app_wrap_components + # Both pages share the same subtree hash, so exactly one wrapper tag is registered. + assert len(compile_ctx.memoize_wrappers) == 1 + wrapper_tag = next(iter(compile_ctx.memoize_wrappers)) + assert list(compile_ctx.auto_memo_components) == [wrapper_tag] + # Each page imports the generated experimental memo component. + page_a_code = compile_ctx.compiled_pages["/a"].output_code or "" + assert f'import {{{wrapper_tag}}} from "$/utils/components"' in page_a_code + assert f"jsx({wrapper_tag}," in page_a_code + assert f"const {wrapper_tag} = memo" not in page_a_code + # The removed shared-stateful-components path should not appear anywhere. + assert "$/utils/stateful_components" not in page_a_code + + +def test_compile_context_resets_memoize_wrappers_between_runs() -> None: + """``CompileContext.memoize_wrappers`` is cleared on each compile run.""" + ctx = CompileContext( + pages=[FakePage(route="/a", component=create_shared_stateful_component)], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx: + ctx.compile() + first_tags = set(ctx.memoize_wrappers) + first_defs = set(ctx.auto_memo_components) + assert first_tags # memoize wrapper was registered + assert first_defs == first_tags + + # Re-compile with a different page set → wrappers reset, not accumulated. + ctx2 = CompileContext( + pages=[FakePage(route="/c", component=create_shared_stateful_component)], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx2: + ctx2.compile() + + # Same shared component → same tag, not a union across runs. + assert set(ctx2.memoize_wrappers) == first_tags + assert set(ctx2.auto_memo_components) == first_tags + page_ctx = ctx2.compiled_pages["/c"] + assert "react-moment" in page_ctx.frontend_imports + assert "$/utils/stateful_components" not in (page_ctx.output_code or "") + + +def test_compile_context_applies_style_before_inline_stateful_render() -> None: + compile_ctx = CompileContext( + pages=[ + FakePage( + route="/styled", + component=create_inline_stateful_component, + ) + ], + hooks=CompilerHooks( + plugins=default_page_plugins( + style={InlineStatefulComponent: {"color": "red"}} + ) + ), + ) + + with compile_ctx: + compile_ctx.compile() + + assert '["color"] : "red"' in ( + compile_ctx.compiled_pages["/styled"].output_code or "" + ) + + +def test_compile_context_applies_style_before_shared_stateful_render() -> None: + compile_ctx = CompileContext( + pages=[ + FakePage(route="/a", component=create_shared_stateful_component), + FakePage(route="/b", component=create_shared_stateful_component), + ], + hooks=CompilerHooks( + plugins=default_page_plugins( + style={SharedLibraryComponent: {"color": "red"}} + ) + ), + ) + + with compile_ctx: + compile_ctx.compile() + + assert '["color"] : "red"' in (compile_ctx.compiled_pages["/a"].output_code or "") + assert '["color"] : "red"' in (compile_ctx.compiled_pages["/b"].output_code or "") diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 1fd315286e8..31dc10b0e08 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -7,7 +7,6 @@ CUSTOM_COMPONENTS, Component, CustomComponent, - StatefulComponent, custom_component, ) from reflex_base.constants import EventTriggers @@ -1165,54 +1164,6 @@ def test_format_component(component, rendered): assert str(component) == rendered -def test_stateful_component(test_state: type[TestState]): - """Test that a stateful component is created correctly. - - Args: - test_state: A test state. - """ - stateful_component_cache: dict[str, StatefulComponent] = {} - text_component = rx.text(test_state.num) - stateful_component = StatefulComponent.compile_from( - text_component, - stateful_component_cache=stateful_component_cache, - ) - assert isinstance(stateful_component, StatefulComponent) - assert stateful_component.tag is not None - assert stateful_component.tag.startswith("Text_") - assert stateful_component.references == 1 - sc2 = StatefulComponent.compile_from( - rx.text(test_state.num), - stateful_component_cache=stateful_component_cache, - ) - assert isinstance(sc2, StatefulComponent) - assert stateful_component.references == 2 - assert sc2.references == 2 - - -def test_stateful_component_memoize_event_trigger(test_state: type[TestState]): - """Test that a stateful component is created correctly with events. - - Args: - test_state: A test state. - """ - button_component = rx.button("Click me", on_blur=test_state.do_something) - stateful_component = StatefulComponent.compile_from(button_component) - assert isinstance(stateful_component, StatefulComponent) - - # No event trigger? No StatefulComponent - assert not isinstance( - StatefulComponent.compile_from(rx.button("Click me")), StatefulComponent - ) - - -def test_stateful_banner(): - """Test that a stateful component is created correctly with events.""" - connection_modal_component = rx.connection_modal() - stateful_component = StatefulComponent.compile_from(connection_modal_component) - assert isinstance(stateful_component, StatefulComponent) - - TEST_VAR = LiteralVar.create("p")._replace( merge_var_data=VarData( hooks={"useTest": None}, From 50e8877098468b7ee34d7fe74aa8cb28552ea39a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 02:03:57 +0500 Subject: [PATCH 21/31] Add _validate_component_children bypass for experimental memo wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memo wrappers are transparent in the authored component tree — they should not trigger _valid_parents checks against themselves. Override _validate_component_children on ExperimentalMemoComponent to skip the check, preventing false validation failures when a restricted child (e.g. _valid_parents = ["ValidParent"]) is wrapped in a memo before being placed inside its valid parent. --- reflex/experimental/memo.py | 13 +++++++++++++ tests/units/experimental/test_memo.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index a5f321a900b..b6904e6955c 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -76,6 +76,19 @@ class ExperimentalMemoComponent(Component): library = f"$/{constants.Dirs.COMPONENTS_PATH}" + def _validate_component_children(self, children: list[Component]) -> None: + """Skip direct parent/child validation for memo wrapper instances. + + Experimental memos wrap an underlying compiled component definition. + The runtime wrapper should not interpose on `_valid_parents` checks for + the authored subtree because the wrapper itself is not the semantic + parent in the user-authored component tree. + + Args: + children: The children of the component (ignored). + """ + del children + def _post_init(self, **kwargs): """Initialize the experimental memo component. diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index f202ecf05d8..236b6b115ad 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -415,6 +415,29 @@ def wrapper() -> rx.Component: assert definition.component.style == Style() +def test_component_returning_memo_is_transparent_for_child_validation(): + """Experimental memo wrappers should not break `_valid_parents` checks.""" + + class ValidParent(Component): + tag = "ValidParent" + library = "valid-parent" + + class RestrictedChild(Component): + tag = "RestrictedChild" + library = "restricted-child" + _valid_parents = ["ValidParent"] + + @rx._x.memo + def transparent(children: rx.Var[rx.Component]) -> rx.Component: + return children # type: ignore[return-value] + + wrapped_child = transparent(RestrictedChild.create()) + parent = ValidParent.create(wrapped_child) + + assert isinstance(wrapped_child, ExperimentalMemoComponent) + assert parent.children == [wrapped_child] + + def test_compile_memo_components_includes_experimental_custom_code(): """Experimental component memos should include custom code in compiled output.""" From 5f09c98b91e7ebc873c4f2ced6e527ba6b77d161 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 17:15:45 +0500 Subject: [PATCH 22/31] fix test --- tests/integration/test_auto_memo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_auto_memo.py b/tests/integration/test_auto_memo.py index 2f6e8a0daa0..121184f493e 100644 --- a/tests/integration/test_auto_memo.py +++ b/tests/integration/test_auto_memo.py @@ -15,7 +15,7 @@ def AutoMemoAcrossPagesApp(): import reflex as rx def shared_counter() -> rx.Component: - return rx.text(rx.State.router.path, id="shared-value") + return rx.text(rx.State.router.page.raw_path, id="shared-value") def index() -> rx.Component: return rx.vstack( From 3dc14f152ff4fbc3c28c61ddf3bad0b68e92fac5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 21:53:38 +0500 Subject: [PATCH 23/31] fixed buffer upload --- .pre-commit-config.yaml | 5 +- .../event/processor/event_processor.py | 2 + pyi_hashes.json | 119 ------------------ 3 files changed, 3 insertions(+), 123 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78079e1b880..5db33b0809f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,11 @@ repos: - args: - reflex - tests - exclude: ^docs/ id: ruff-format - args: - --fix - --exit-non-zero-on-fix - exclude: ^(integration/benchmarks/|docs/) + exclude: ^integration/benchmarks/ id: ruff-check repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.6 @@ -34,7 +33,6 @@ repos: - args: - reflex - tests - exclude: ^docs/ id: pyright language: system repo: https://github.com/RobertCraigie/pyright-python @@ -47,7 +45,6 @@ repos: - '2' - --indent-style - space - exclude: ^docs/ id: biome-format repo: https://github.com/biomejs/pre-commit rev: v0.6.1 diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index f22c4fb78ba..de8e6948768 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -442,6 +442,8 @@ async def _emit_delta_impl( finally: for future in waiting_for: future.cancel() + if not task_future.done(): + task_future.cancel() # Raise any exceptions for the caller, waiting for all chained events. await task_future.wait_all() diff --git a/pyi_hashes.json b/pyi_hashes.json index 64d50de290a..0862b4679dd 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,123 +1,4 @@ { - "packages/reflex-components-code/src/reflex_components_code/code.pyi": "2797061144c4199f57848f6673a05a7f", - "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "db0de2879d57870831a030a69b5282b7", - "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", - "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", - "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "e7dfa98f5df5e30cb6d01d61b6974bef", - "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "0f98a7c1247e35059b76ae2985b7c81b", - "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "80a3090e5b7a46de6daa8e97e68e8638", - "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "f36f27e580041af842d348adbddcd600", - "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "39abed241f2def793dd0c59328bb0470", - "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "05d96de8a1d5f7be08de831b99663e67", - "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "b83e94900f988ef5d2fdf121b01be7fa", - "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "cfb0d5bcfe67f7c2b40868cdf3a5f7c1", - "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8a69093c8d40b10b1f0b1c4e851e9d53", - "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", - "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "29f5c106b98ddac94cf7c1244a02cfb1", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "9af2721b01868b24a48c7899ad6b1c69", - "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "20a3f4f500d44ac4365b6d831c6816ff", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "eb606cf8151e6769df7f2443ece739cd", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5e28d554d2b4d7fae1ba35809c24f4fc", - "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "28bd59898f0402b33c34e14f3eef1282", - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "8619aba44cf2568a5c45de9975251722", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "dbefc8e2ec126b4ed878d69d0d233999", - "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", - "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", - "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "1a8824cdd243efc876157b97f9f1b714", - "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", - "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c74980207dc1a5cac14083f2edd31ba", - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "da7ef00fd67699eeeb55e33279c2eb8d", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "0ea0058ea7b6ae03138c7c85df963c32", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "97f7f6c66533bb3947a43ceefe160d49", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "7ea09671a42d75234a0464fc3601577c", - "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "869dca86b783149f9c59e1ae0d2900c1", - "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "c3a5a4f2d0594414a160fe59b13ccc26", - "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "b2acdc964feabe78154be141dc978555", - "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "e75fbe0454df06abf462ab579b698897", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "f88089a2f4270b981a28e385d07460b5", - "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "c5ac8ba14fdce557063a832a79f43f68", - "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "e10210239ce7dc18980e70eec19b9353", - "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2a93782c63e82a6939411273fe2486d9", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "f654cc9cb305712b485fcd676935c0c1", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "2d6efa2d5f2586a7036d606a24fb425d", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "ad4b084d94e50311f761d69b3173e357", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "241b80584f3e029145e6e003d1c476f2", - "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "b2f485bfde4978047b7b944cf15d92cb", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "18ed34323f671fcf655639dc78d7c549", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "9c80e740d177b4a805dee3038d580941", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "b47313aefc9a740851ee332656446afd", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "d6a4f88f2988fa50fbed8a9026f5ef8b", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "00c0e0b6c8190f2db7fd847a25b5c03d", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "577ec9714a4d8bc9f7dd7eca22fe5252", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "bc69b9443d04ae7856c0a411a90755a9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", - "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "90a182a1444b73c006e52ea67c2b3db1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "2b8c68239c9e9646e71ef8e81d7b5f69", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0f981ee0589f5501ab3c57e0aec01316", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "d30f1bfb42198177ea08d7d358e99339", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "c3bb335b309177ff03d2cadcaf623744", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "6a01812d601e8bf3dcd30dcccc75cb79", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "9b853e851805addacc2fcd995119f857", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "67a71ec6ed4945a9ce270bd51d40b94e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "0c975a4812efc267c87119f10880e1a9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "6425aae44ffe78f48699910906d16285", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "d0029ee04a971d8a51be0c99e414a139", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "1ee25c7dd27fece9881800226e322d6b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "924addbc155a178709f5fd38af4eb547", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "e315e9779663f2f2fc9c2ca322a5645f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "ec6cb8830971b2a04bebe7459c059b15", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "28384945a53620ad6075797f8ada7354", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "6a3a37bdc9136f8c19fb3a7f55e76d64", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "05cfece835e2660bbc1b096529dfdec0", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "3033070773e8e32de283ad917367b386", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "798eadec25895a56e36d23203a4e0444", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "f6140dbf7ad4c25595c6983dcacc2a60", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "e16ca79a2ad4c2919f56efb54830c1ef", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "473703616ed18d983dda3600899710a5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "12eb86d24886764bf1a5815e87ea519c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "6319f89d046b0fce8e9efb51e50dda9f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "c6da1db236da70dc40815a404d2e29b3", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "d2dabb895d7fc63a556d3c3220e38b4d", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "55b003f62cc3e5c85c90c82f8f595bc6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "c204f30612bfa35a62cb9f525a913f77", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "faeddfd0e3dc0e3bbcfdeaa6e42cb755", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "70f1d8fc55398d3cbb01f157c768419e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "a4c3052bc449924a630dad911f975e26", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ec4e4ed03bd892c6f7d50ae4b490adb9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "06549c800759ae541cc3c3a74240af59", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "dcb6a8ff4668082fc9406579098abf87", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "69e4ce4eeaa60ac90ef120331cb8601c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "dcbb1dc8e860379188924c15dd21605b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "28e6cd3869c9cbad886b69b339e3ecf6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "004cae8160c3a91ae6c12b54205f5112", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "9dbe595eddc2ec731beeb3a98743be36", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "1fb9d0ce37de9c64f681ad70375b9e42", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "a729044bfe2d82404de07c4570262b55", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "74b017b63728ce328e110bc64f20a205", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "3a595ec7faf95645ab52bdad1bf9dc4a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "f3e44e291f3d96d06850d262de5d43a8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "a0a59ca93ea1e3a0e5136b9692a68d18", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "6ab750e790f0687b735d7464fa289c1f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "3dd8bc1d7117b4e2b3b38438b4d6631a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "a71f56a8c51e9b00f953d87b16724bdb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "47a5f03dc4c85c473026069d23b6c531", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "ced137b2820a5e156cd1846ff113cfc9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "014444973b21272cf8c572b2913dfdf5", - "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "2c3c398ec0cc1476995f316cf8d0d271", - "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "9f8631e66d64f8bed90cbfd63615a97a", - "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "d0efeacb8b4162e9ace79f99c03e4368", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", - "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "9e99f951112c86ec7991bc80985a76b1", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "5730b770af97f8c67d6d2d50e84fe14d", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "4097350ca05011733ce998898c6aefe7", - "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "db5298160144f23ae7abcaac68e845c7", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "75150b01510bdacf2c97fca347c86c59", - "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "dc43e142b089b1158588e999505444f6", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "65306e737dac21981bdb361da84d43db" From af85fa210669565bc533f6974fd7f55ff04749dd Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 22:57:02 +0500 Subject: [PATCH 24/31] fixed buffer upload by shortcircuiting --- .../event/processor/event_processor.py | 5 ++ .../reflex_components_core/core/_upload.py | 83 ++++++++++++++++++- .../event/processor/test_event_processor.py | 24 ++++++ tests/units/test_app.py | 73 ++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index de8e6948768..5e025855e07 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -380,6 +380,7 @@ async def enqueue_stream_delta( self, token: str, event: Event, + on_task_future: Callable[[EventFuture], None] | None = None, ) -> AsyncGenerator[Mapping[str, Any]]: """Enqueue an event to be processed and yield deltas emitted by the event handler. @@ -393,6 +394,8 @@ async def enqueue_stream_delta( Args: token: The client token associated with the event. event: The event to be enqueued. + on_task_future: Optional callback invoked with the EventFuture for the + enqueued handler as soon as it is created. Yields: Deltas emitted by the event handler for the specified token. @@ -425,6 +428,8 @@ async def _emit_delta_impl( emit_delta_impl=_emit_delta_impl, ), ) + if on_task_future is not None: + on_task_future(task_future) all_task_futures = asyncio.create_task(task_future.wait_all()) waiting_for = {all_task_futures, asyncio.create_task(deltas.get())} try: diff --git a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py index 2e8d80b1052..40c450582ea 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py @@ -22,7 +22,8 @@ from typing_extensions import Self if TYPE_CHECKING: - from reflex_base.utils.types import Receive, Scope, Send + from reflex_base.event.processor import EventFuture + from reflex_base.utils.types import Message, Receive, Scope, Send from reflex.app import App @@ -403,20 +404,70 @@ class _UploadStreamingResponse(StreamingResponse): """Streaming response that always releases upload form resources.""" _on_finish: Callable[[], Awaitable[None]] + _on_disconnect: Callable[[], None] | None + _disconnect_handled: bool def __init__( self, *args: Any, on_finish: Callable[[], Awaitable[None]], + on_disconnect: Callable[[], None] | None = None, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) self._on_finish = on_finish + self._on_disconnect = on_disconnect + self._disconnect_handled = False + + def _handle_disconnect(self) -> None: + """Run disconnect cleanup exactly once.""" + if self._disconnect_handled or self._on_disconnect is None: + return + self._disconnect_handled = True + self._on_disconnect() + + async def _watch_disconnect(self, receive: Receive) -> None: + """Wait for the client connection to close.""" + while True: + message = await receive() + if message["type"] == "http.disconnect": + self._handle_disconnect() + return async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + spec_version = tuple( + map(int, scope.get("asgi", {}).get("spec_version", "2.0").split(".")) + ) + disconnect_task: asyncio.Task[None] | None = None + use_watcher = spec_version >= (2, 4) and self._on_disconnect is not None + + async def wrapped_receive() -> Message: + message = await receive() + if message.get("type") == "http.disconnect": + self._handle_disconnect() + return message + try: - await super().__call__(scope, receive, send) + if use_watcher: + # ASGI >= 2.4: use a dedicated task to watch for disconnect + # concurrently. Pass raw `receive` to Starlette — the watcher + # owns disconnect detection; using wrapped_receive here would + # race on the same receive callable. + disconnect_task = asyncio.create_task(self._watch_disconnect(receive)) + try: + await super().__call__( + scope, + wrapped_receive if not use_watcher else receive, + send, + ) + except ClientDisconnect: + self._handle_disconnect() + raise finally: + if disconnect_task is not None: + disconnect_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await disconnect_task await self._on_finish() @@ -515,6 +566,27 @@ def _create_upload_event() -> Event: msg = "Upload event was not created." raise RuntimeError(msg) + task_future: EventFuture | None = None + disconnect_seen = False + + def _try_cancel() -> None: + """Cancel the task future if it exists and is still running.""" + if task_future is not None and not task_future.done(): + task_future.cancel() + + def _remember_task_future(future: EventFuture) -> None: + """Keep a handle to the upload task for disconnect cancellation.""" + nonlocal task_future + task_future = future + if disconnect_seen: + _try_cancel() + + def _cancel_upload_task() -> None: + """Cancel the queued upload handler when the client disconnects.""" + nonlocal disconnect_seen + disconnect_seen = True + _try_cancel() + async def _ndjson_updates(): """Process the upload event, generating ndjson updates. @@ -522,13 +594,18 @@ async def _ndjson_updates(): Each state update as newline-delimited JSON. """ # Enqueue the task on the main event loop, but emit deltas to the local queue. - async for delta in app.event_processor.enqueue_stream_delta(token, event): + async for delta in app.event_processor.enqueue_stream_delta( + token, + event, + on_task_future=_remember_task_future, + ): yield json_dumps(StateUpdate(delta=delta)) + "\n" return _UploadStreamingResponse( _ndjson_updates(), media_type="application/x-ndjson", on_finish=_close_form_data, + on_disconnect=_cancel_upload_task, ) diff --git a/tests/units/reflex_base/event/processor/test_event_processor.py b/tests/units/reflex_base/event/processor/test_event_processor.py index de1ea4dcb23..ee7d0c9b5c8 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -518,6 +518,30 @@ async def test_stream_delta_not_configured_raises(): pass +async def test_stream_delta_calls_on_task_future(token: str): + """enqueue_stream_delta exposes the tracked EventFuture immediately. + + Args: + token: The client token. + """ + ep = EventProcessor(graceful_shutdown_timeout=2) + ep.configure() + captured = [] + async with ep: + event = Event.from_event_type(noop_event())[0] + collected = [ + d + async for d in ep.enqueue_stream_delta( + token, + event, + on_task_future=captured.append, + ) + ] + assert collected == [] + assert len(captured) == 1 + assert captured[0].done() + + async def test_sequential_chained_events_run_in_order(token: str): """Chained events enqueued by a handler run in the order they were enqueued. diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 42d48faa608..f4d95bfe0f1 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1306,6 +1306,79 @@ async def send(_message): assert bio.closed +@pytest.mark.asyncio +async def test_upload_file_cancels_buffered_handler_on_disconnect_before_future_capture( + token: str, +): + """Buffered uploads cancel the handler even if disconnect wins the race. + + This exercises the ASGI 2.4 path where the response must watch + ``receive()`` directly because Starlette does not listen for disconnects + while streaming the response body. + + Args: + token: A token. + """ + request_mock = unittest.mock.Mock() + request_mock.headers = { + "reflex-client-token": token, + "reflex-event-handler": f"{FileUploadState.get_full_name()}.multi_handle_upload", + } + + bio = io.BytesIO(b"contents of image one") + file1 = UploadFile(filename="image1.jpg", file=bio) + form_data = FormData([("files", file1)]) + original_close = form_data.close + form_close = AsyncMock(side_effect=original_close) + form_data.close = form_close + + async def form(): # noqa: RUF029 + return form_data + + request_mock.form = form + + cancelled = asyncio.Event() + task_future = Mock() + task_future.done = Mock(side_effect=cancelled.is_set) + task_future.cancel = Mock(side_effect=cancelled.set) + + async def enqueue_stream_delta(_token, _event, on_task_future=None): + assert on_task_future is not None + on_task_future(task_future) + await cancelled.wait() + if False: # pragma: no cover + yield {} + + app = Mock( + event_processor=Mock(enqueue_stream_delta=enqueue_stream_delta), + ) + + upload_fn = upload(app) + streaming_response = await upload_fn(request_mock) + + assert isinstance(streaming_response, StreamingResponse) + + async def receive(): + await asyncio.sleep(0) + return {"type": "http.disconnect"} + + async def send(_message): # noqa: RUF029 + return None + + await asyncio.wait_for( + streaming_response( + {"type": "http", "asgi": {"spec_version": "2.4"}}, + receive, + send, + ), + timeout=1, + ) + + assert task_future.cancel.call_count == 1 + assert form_close.await_count == 1 + assert bio.closed + + @pytest.mark.asyncio @pytest.mark.parametrize( "state", From 620c84d7c3f78aafe209bcdff94ea2456f39a8e4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 8 Apr 2026 23:53:35 +0500 Subject: [PATCH 25/31] Skip buffered upload handler when probe chunk detects client disconnect Insert a b"\n" probe chunk before the real response body so that an early client disconnect is detected before the upload handler is enqueued. Add asyncio.sleep(0) yield point in _upload_buffered_file to let the disconnect watcher fire first, and return early if disconnect was seen. Includes a test covering the probe-based disconnect detection path. --- .../reflex_components_core/core/_upload.py | 26 +++++-- tests/units/test_app.py | 69 +++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py index 40c450582ea..3ff1661f418 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py @@ -441,18 +441,30 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: disconnect_task: asyncio.Task[None] | None = None use_watcher = spec_version >= (2, 4) and self._on_disconnect is not None + if use_watcher: + body_iterator = self.body_iterator + + async def body_with_probe() -> AsyncGenerator[ + str | bytes | memoryview[int], None + ]: + """Yield a tiny probe chunk before the real response body.""" + yield b"\n" + async for chunk in body_iterator: + yield chunk + + self.body_iterator = body_with_probe() + async def wrapped_receive() -> Message: message = await receive() - if message.get("type") == "http.disconnect": + if message["type"] == "http.disconnect": self._handle_disconnect() return message try: if use_watcher: - # ASGI >= 2.4: use a dedicated task to watch for disconnect - # concurrently. Pass raw `receive` to Starlette — the watcher - # owns disconnect detection; using wrapped_receive here would - # race on the same receive callable. + # ASGI >= 2.4: Starlette does not call receive() while + # streaming. Use a dedicated task so disconnect fires the + # callback; pass raw receive to avoid racing wrapped_receive. disconnect_task = asyncio.create_task(self._watch_disconnect(receive)) try: await super().__call__( @@ -593,6 +605,10 @@ async def _ndjson_updates(): Yields: Each state update as newline-delimited JSON. """ + # Let the disconnect watcher run before we enqueue the upload handler. + await asyncio.sleep(0) + if disconnect_seen: + return # Enqueue the task on the main event loop, but emit deltas to the local queue. async for delta in app.event_processor.enqueue_stream_delta( token, diff --git a/tests/units/test_app.py b/tests/units/test_app.py index f4d95bfe0f1..8efb3bfb01b 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -30,6 +30,7 @@ from reflex_components_radix.themes.typography.text import Text from starlette.applications import Starlette from starlette.datastructures import FormData, Headers, UploadFile +from starlette.requests import ClientDisconnect from starlette.responses import StreamingResponse from starlette_admin.auth import AuthProvider @@ -1379,6 +1380,74 @@ async def send(_message): # noqa: RUF029 assert bio.closed +@pytest.mark.asyncio +async def test_upload_file_skips_buffered_handler_when_disconnect_detected_on_probe( + token: str, +): + """Buffered uploads skip handler dispatch when the probe send disconnects. + + This models ASGI 2.4+ behavior where the upload request can finish parsing, + but the client disconnect is only surfaced on the first response-body send. + + Args: + token: A token. + """ + request_mock = unittest.mock.Mock() + request_mock.headers = { + "reflex-client-token": token, + "reflex-event-handler": f"{FileUploadState.get_full_name()}.multi_handle_upload", + } + + bio = io.BytesIO(b"contents of image one") + file1 = UploadFile(filename="image1.jpg", file=bio) + form_data = FormData([("files", file1)]) + original_close = form_data.close + form_close = AsyncMock(side_effect=original_close) + form_data.close = form_close + + async def form(): # noqa: RUF029 + return form_data + + request_mock.form = form + + msg = "upload handler should not be enqueued" + probe_chunk = b"\n" + asgi_24_scope = {"type": "http", "asgi": {"spec_version": "2.4"}} + enqueue_stream_delta = Mock(side_effect=AssertionError(msg)) + app = Mock( + event_processor=Mock(enqueue_stream_delta=enqueue_stream_delta), + ) + + upload_fn = upload(app) + streaming_response = await upload_fn(request_mock) + + assert isinstance(streaming_response, StreamingResponse) + + async def receive(): + await asyncio.sleep(0) + return {"type": "http.disconnect"} + + async def send(message): + await asyncio.sleep(0) + if ( + message.get("type") == "http.response.body" + and message.get("body") == probe_chunk + ): + err = "client disconnected" + raise OSError(err) + + with pytest.raises(ClientDisconnect): + await streaming_response( + asgi_24_scope, + receive, + send, + ) + + assert enqueue_stream_delta.call_count == 0 + assert form_close.await_count == 1 + assert bio.closed + + @pytest.mark.asyncio @pytest.mark.parametrize( "state", From e7dfb8121fbae1d8c6ef0f780b6135bb709a629c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 9 Apr 2026 00:14:35 +0500 Subject: [PATCH 26/31] pyi hashes --- pyi_hashes.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 01d72078c49..d515a137fa1 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -20,8 +20,8 @@ "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "257b7d1ff394d7dfb79fc6e9bf583463", "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "0515ecd0f7a1e6175b5781ee2a15a519", "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "c13b4c9ddeccc854f7d4f735b6b8bf35", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "f5529c6cb678c5287d5b06c7e288bce6", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "ccbd7f4c55eb499a058b4822db3639a3", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "61f29d6489915bffb43eb398bcfb4d00", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "bb76f741c2849a11b14bfdb6a95cb264", "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "3250cce0348494dac0075468bdc6daae", @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "94fd94b9e127bbc98b7bf0011d6305fa", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "5912c6017337c852fff42cdfcf95cd6c" + "reflex/experimental/memo.pyi": "100ec039af46a5b0225da521ca4fbd6a" } From 2e47971a736442ea8494671f0bec4b5a1e8e93f4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Mon, 13 Apr 2026 21:57:37 +0500 Subject: [PATCH 27/31] fix: collect imports from component-valued props Component-valued props were dropped from frontend imports because the collector short-circuited on `in_prop_tree` and `_get_all_imports` only recursed into `children`. Always collect the component"s own imports in the plugin and walk `_get_components_in_props()` in `_get_all_imports` so libs referenced solely via props (e.g. slots) are emitted. --- .../src/reflex_base/components/component.py | 7 +++++- reflex/compiler/plugins/builtin.py | 14 +++++++----- tests/units/compiler/test_plugins.py | 5 +++++ tests/units/components/test_component.py | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index b5082456472..b69d99497f2 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1767,7 +1767,12 @@ def _get_all_imports(self, collapse: bool = False) -> ParsedImportDict: The import dict with the required imports. """ imports_ = imports.merge_parsed_imports( - self._get_imports(), *[child._get_all_imports() for child in self.children] + self._get_imports(), + *[child._get_all_imports() for child in self.children], + *[ + component._get_all_imports() + for component in self._get_components_in_props() + ], ) return imports.collapse_imports(imports_) if collapse else imports_ diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 822352dc5dc..0fcfdcef647 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -175,10 +175,11 @@ def enter_component( if not isinstance(comp, Component): return + imports = comp._get_imports() + if imports: + self._extend_imports(page_context.frontend_imports, imports) + if not in_prop_tree: - imports = comp._get_imports() - if imports: - self._extend_imports(page_context.frontend_imports, imports) self._collect_component_custom_code(page_context.module_code, comp) self._collect_component_hooks(page_context.hooks, comp) @@ -254,10 +255,11 @@ def enter_component( if not isinstance(comp, Component): return + imports_for_component = comp._get_imports() + if imports_for_component: + extend_imports(frontend_imports, imports_for_component) + if not in_prop_tree: - imports_for_component = comp._get_imports() - if imports_for_component: - extend_imports(frontend_imports, imports_for_component) collect_component_custom_code(module_code, comp) collect_component_hooks(hooks, comp) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index e406c300818..86587d39be2 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -729,12 +729,15 @@ def test_apply_style_plugin_matches_legacy_style_behavior() -> None: def test_default_collector_matches_legacy_collectors() -> None: component = create_component_tree() + assert "prop-lib" in component._get_all_imports(collapse=True) + page_ctx = collect_page_context( component, plugins=(DefaultCollectorPlugin(),), ) assert page_ctx.imports == [component._get_all_imports(collapse=True)] + assert "prop-lib" in page_ctx.frontend_imports assert page_ctx.hooks == component._get_all_hooks() assert "usePropHook" not in "".join(page_ctx.hooks) assert page_ctx.module_code == component._get_all_custom_code() @@ -779,7 +782,9 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: assert isinstance(page_ctx.root_component, Component) assert page_ctx.name == "create_component_tree" assert page_ctx.route == "/demo" + assert "prop-lib" in page_ctx.root_component._get_all_imports(collapse=True) assert page_ctx.frontend_imports == page_ctx.merged_imports(collapse=True) + assert "prop-lib" in page_ctx.frontend_imports compile_ctx_imports = collapse_imports(compile_ctx.all_imports) for lib, fields in page_ctx.frontend_imports.items(): assert lib in compile_ctx_imports diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 31dc10b0e08..e891bd5431f 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -8,6 +8,7 @@ Component, CustomComponent, custom_component, + field, ) from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER @@ -521,6 +522,27 @@ def test_get_imports(component1, component2): } +def test_get_imports_includes_components_in_props(): + """Test that component-valued props contribute their imports.""" + + class PropComponent(Component): + tag = "PropComponent" + library = "prop-lib" + + class ParentComponent(Component): + tag = "ParentComponent" + library = "parent-lib" + + slot: Component | None = field(default=None) + + imports_ = ParentComponent.create(slot=PropComponent.create())._get_all_imports() + + assert imports_ == parse_imports({ + "parent-lib": ["ParentComponent"], + "prop-lib": ["PropComponent"], + }) + + def test_get_custom_code(component1: Component, component2: Component): """Test getting the custom code of a component. From 731b3e3f0b08436f2cebecc20aa8ef9307ec8abc Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 14 Apr 2026 21:45:21 +0500 Subject: [PATCH 28/31] feat: add on_disconnect callback to DisconnectAwareStreamingResponse --- .../reflex_base/utils/streaming_response.py | 21 +++- pyi_hashes.json | 119 ------------------ 2 files changed, 20 insertions(+), 120 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/streaming_response.py b/packages/reflex-base/src/reflex_base/utils/streaming_response.py index d9907379cef..147fa08c2ce 100644 --- a/packages/reflex-base/src/reflex_base/utils/streaming_response.py +++ b/packages/reflex-base/src/reflex_base/utils/streaming_response.py @@ -63,11 +63,13 @@ class DisconnectAwareStreamingResponse(StreamingResponse): """Streaming response that cancels its body task on disconnect.""" _on_finish: Callable[[], Awaitable[None]] + _on_disconnect: Callable[[], None] | None def __init__( self, *args: Any, on_finish: Callable[[], Awaitable[None]], + on_disconnect: Callable[[], None] | None = None, **kwargs: Any, ) -> None: """Initialize the response. @@ -75,10 +77,17 @@ def __init__( Args: args: Positional args forwarded to ``StreamingResponse``. on_finish: Cleanup callback to run exactly once when the response ends. + on_disconnect: Sync callback invoked when the client disconnects. kwargs: Keyword args forwarded to ``StreamingResponse``. """ super().__init__(*args, **kwargs) self._on_finish = on_finish + self._on_disconnect = on_disconnect + + def _notify_disconnect(self) -> None: + """Invoke the on_disconnect callback if one was provided.""" + if self._on_disconnect is not None: + self._on_disconnect() async def _watch_disconnect(self, receive: Receive) -> None: """Wait for the client connection to close.""" @@ -107,7 +116,16 @@ async def wrap(func: Callable[[], Awaitable[None]]) -> None: task_group.cancel_scope.cancel() task_group.start_soon(wrap, partial(self.stream_response, send)) - await wrap(partial(self.listen_for_disconnect, receive)) + + if self._on_disconnect is not None: + + async def _disconnect_then_notify() -> None: + await self.listen_for_disconnect(receive) + self._notify_disconnect() + + await wrap(_disconnect_then_notify) + else: + await wrap(partial(self.listen_for_disconnect, receive)) else: # Verified against Starlette 0.52.1: the ASGI >= 2.4 path in # StreamingResponse.__call__ delegates straight to @@ -125,6 +143,7 @@ async def wrap(func: Callable[[], Awaitable[None]]) -> None: return_when=asyncio.FIRST_COMPLETED, ) if disconnect_task in done and not stream_task.done(): + self._notify_disconnect() should_close_body_iterator = True stream_task.cancel() with contextlib.suppress(asyncio.CancelledError): diff --git a/pyi_hashes.json b/pyi_hashes.json index 88eb9573581..b5c5b8c6c2c 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,123 +1,4 @@ { - "packages/reflex-components-code/src/reflex_components_code/code.pyi": "a879ccd253e901964a7ab7ea7154f904", - "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "d3e0c33fdc34f5c154ac387d550c0d29", - "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", - "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", - "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "ecccfd8a9b0e8b2f4128ff13ff27a9da", - "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "2535814d409e5feaf57da63dcf0abeaf", - "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "a2e67a9814dc61853ca2299d9d9c698d", - "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "59170074a1a228ce58685f3f207954f2", - "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "e4cbfc46eabb904596be4372392add35", - "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "629a483c570b04ca3d83ecdc53914770", - "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "0cfa2d8c52321ce7440e887d03007d5b", - "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "bfc7fb609b822f597d1141595f8090fe", - "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", - "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", - "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", - "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "dd221754c5e076a3a833c8584da72dc5", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", - "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6", - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "ac589d6237fe51414d536b9d70de5dec", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6", - "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", - "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", - "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "ff68d843c5987d3f0d773a6367eb9c63", - "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", - "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "d2500a39e6e532bb90c83438343905bf", - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "ca840a20c8e1c1f5335fb815a25b6c32", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "c38a432d1fd0c3208c4fc3a546c67e4d", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "b794f4f4f7ad17c6939d5526b9c63397", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "f9e51feebda79fb063bc264a235df0c3", - "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "c86abf00384b5f15725a0daf2533848d", - "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "222176bffc14191018fd0e3af3741aff", - "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "fbbe0bf222d4196c32c88d05cb077997", - "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "cba93678248925c981935a251379aa7c", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "4abedc6f98f6d54194ff9e7f1f76314e", - "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", - "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "07de150d57e16f66b62d66a94da98d74", - "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "3892ce64fef33649813a25f63c0ba43b", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", - "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "4d6121ccc963c64e33c49acd4295eb7a", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "b3b66ec57525c53ea741897e2bc8370e", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "c86bc8d4604e3d8c8d40baad2ac6dc17", - "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", - "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "e75cbf2a34620721432b1556f3c875cd", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ed020269e4728cc6abe72354193146b7", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "d5e0419729df4ddf2caf214f40ae7845", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "613abb9870259547c99eb434a3a17512", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "1671e796449b236386d8f53d33e42b2f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "9fde9929ca5197e0e1880bce9a08e926", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "e5d6387a93c74dafaa0d6f1719e08bac", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "09487ef45cf26edb0b7c1d6da5f097f0", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "76afb58340c6be1f26b7b110473efa55", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "6cbf013e21d7280118dfd7383998b3bf", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "63b4134246f68f9f556896d6ce194462", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "ec3f89e7d187303344d4127a83522b22", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "99541ee46f112eb4096f903a99f5ffb8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "1dfd91741ff402b3ed93b6daca4939f3", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "ed9198da4a7950a8579e50ad970c34ef", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "15f9cee0584414f2d2e0fb82c167f216", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "3f328bb0ba5225e4478febf8c7623833", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "be8eed28e19221a406e554829809ff0d", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "9b2adf18f7d239b8e7431f39042ed301", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "4d5813a47b8f8b6ac317ca01d87d9afb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "1ab01f45a4c5ef4211eacc00cc99e4a5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "38a7412205a98617f98218a5b213ada1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "d84b16ac16083a534199fd23659aaa06", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "51fda6313f1ce86d5b1ffdfd68ae8b74", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "bba40e5eae75314157378c9e8b0eea73", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "bf9f751a701137bfedc254657d4c5be4", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "605479e11d19dd7730c90125b198c9b6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "519781d33b99c675a12014d400e54d08", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "f4848f7d89abb4c78f6db52c624cdabf", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "61a08374fa19a0bb3f52b8654effc0f2", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "530c51742031389d4b2ae43548ff0f03", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "23b21bc11a0012e13ce9bb79b47ba146", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "8364f40600870bafa585528d9cadedf8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "3a52910c327f55656eb59309f9362361", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "fcf562b2f61ecdcc2de6f70d2ebf9907", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "250b7e77b67e7d8cd3fff2b40526c04c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "799acce0af81899a3a310bdcd43c403b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "9e452af27229b676ad0146e40f75bed5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "5b262189e235cac17182e79188b1681a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "e06c8fd64132765d61b9edb87a48558b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "5aa934d7c6ba3889fa943eabee7dc05f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "c67fafd1aec105cb5a9927ff0e6d2071", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "aa68061a8e5dfd4adf336d1d1cb000fb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "06b92d31331c6f08b5083fcc811b754a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "e7cd3a9cea1c34e21f731f1bd05c1ceb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "8c968fead3155b2d51c687459811b5df", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "cfc8a927642e5b68feabc80080aeb8dc", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "cf88cf870eefaacaf765ead10fb4593b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "92d5a2df77a69a28a4d591000ee46bd1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "8a1e4376cadf4961212d39a5128a0e4f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "34c7ed3fe1e5f702a98d72751b0052fa", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "619a9d8351748fffe76136002931e583", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "4919daa4483b7c12f6fafd02a2275e0f", - "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "0817c9232a6e4790cff8ea8aa6001950", - "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "6c1c26149d57c708fab04b82de0eb515", - "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "75207a9fe4f37ec2a2f1becbbbd5237b", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", - "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "277bbf09d72e0c450241f0b7d39ebb60", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "be20d1d71c3b16f7e973a0329c3d81d6", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "c051ab3a26c23107043e203b060e1412", - "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "234407dbd466bf9c87d75ce979ab0e2d", - "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "792f2ffe75f3acce94af31bd8458a061" From 6b76d1655297a5f50e239721e4a657ae34d7a300 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 14 Apr 2026 22:56:58 +0500 Subject: [PATCH 29/31] fix: replace asyncio.wait with anyio task group in ASGI 2.4 disconnect path asyncio.wait(FIRST_COMPLETED) returns both tasks in `done` when they complete in the same event loop tick, so _notify_disconnect was never called and the upload handler ran despite client disconnect. Use the same anyio task-group pattern as the pre-2.4 path for race-free structured concurrency. --- .../reflex_base/utils/streaming_response.py | 72 +++++++++---------- pyi_hashes.json | 17 +++++ tests/units/test_app.py | 46 +++++------- 3 files changed, 68 insertions(+), 67 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/streaming_response.py b/packages/reflex-base/src/reflex_base/utils/streaming_response.py index 147fa08c2ce..70af5f805dd 100644 --- a/packages/reflex-base/src/reflex_base/utils/streaming_response.py +++ b/packages/reflex-base/src/reflex_base/utils/streaming_response.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import builtins import contextlib import sys @@ -83,9 +82,11 @@ def __init__( super().__init__(*args, **kwargs) self._on_finish = on_finish self._on_disconnect = on_disconnect + self._disconnected = False def _notify_disconnect(self) -> None: """Invoke the on_disconnect callback if one was provided.""" + self._disconnected = True if self._on_disconnect is not None: self._on_disconnect() @@ -127,45 +128,38 @@ async def _disconnect_then_notify() -> None: else: await wrap(partial(self.listen_for_disconnect, receive)) else: - # Verified against Starlette 0.52.1: the ASGI >= 2.4 path in - # StreamingResponse.__call__ delegates straight to - # stream_response(send) and does not read from receive(). - # Keep calling stream_response(send) directly here so the - # disconnect watcher remains the only receive() consumer; if - # Starlette changes that contract, re-check this logic. - stream_task = asyncio.create_task(self.stream_response(send)) - disconnect_task = asyncio.create_task(self._watch_disconnect(receive)) - should_close_body_iterator = False - + # ASGI >= 2.4: Starlette's StreamingResponse.__call__ + # delegates straight to stream_response(send) without + # reading from receive(). We still need a disconnect + # watcher so that the on_disconnect callback fires. + # Use the same anyio task-group pattern as the < 2.4 + # path to avoid asyncio.wait race conditions. try: - done, _ = await asyncio.wait( - {stream_task, disconnect_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - if disconnect_task in done and not stream_task.done(): - self._notify_disconnect() - should_close_body_iterator = True - stream_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await stream_task - else: - try: - await stream_task - except OSError as err: - should_close_body_iterator = True - raise ClientDisconnect from err - finally: - if not disconnect_task.done(): - disconnect_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await disconnect_task - if not stream_task.done(): - should_close_body_iterator = True - stream_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await stream_task - if should_close_body_iterator: - await self._close_body_iterator() + with _collapse_excgroups(): + async with anyio.create_task_group() as task_group: + + async def wrap( + func: Callable[[], Awaitable[None]], + ) -> None: + await func() + task_group.cancel_scope.cancel() + + task_group.start_soon( + wrap, partial(self.stream_response, send) + ) + + async def _disconnect_then_notify() -> None: + await self._watch_disconnect(receive) + self._notify_disconnect() + + await wrap(_disconnect_then_notify) + except OSError as err: + await self._close_body_iterator() + raise ClientDisconnect from err + # anyio cancellation does not call aclose() on the body + # async generator, so close it explicitly on disconnect. + if self._disconnected: + await self._close_body_iterator() finally: await self._on_finish() diff --git a/pyi_hashes.json b/pyi_hashes.json index b5c5b8c6c2c..9df886c0016 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,4 +1,21 @@ { + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "dd221754c5e076a3a833c8584da72dc5", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "ac589d6237fe51414d536b9d70de5dec", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", + "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "e75cbf2a34620721432b1556f3c875cd", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ed020269e4728cc6abe72354193146b7", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "9e452af27229b676ad0146e40f75bed5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "c67fafd1aec105cb5a9927ff0e6d2071", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "e7cd3a9cea1c34e21f731f1bd05c1ceb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "6c1c26149d57c708fab04b82de0eb515", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "be20d1d71c3b16f7e973a0329c3d81d6", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "792f2ffe75f3acce94af31bd8458a061" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 1c74d5afb95..fd71258239c 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -30,7 +30,6 @@ from reflex_components_radix.themes.typography.text import Text from starlette.applications import Starlette from starlette.datastructures import FormData, Headers, UploadFile -from starlette.requests import ClientDisconnect from starlette.responses import StreamingResponse from starlette_admin.auth import AuthProvider @@ -1296,26 +1295,27 @@ async def send(_message): assert form_close.await_count == 0 assert not bio.closed - with pytest.raises(asyncio.CancelledError): - await streaming_response( - {"type": "http", "asgi": {"spec_version": "2.4"}}, - receive, - send, - ) + await streaming_response( + {"type": "http", "asgi": {"spec_version": "2.4"}}, + receive, + send, + ) assert form_close.await_count == 1 assert bio.closed @pytest.mark.asyncio -async def test_upload_file_cancels_buffered_handler_on_disconnect_before_future_capture( +async def test_upload_file_skips_handler_on_disconnect_asgi24( token: str, ): - """Buffered uploads cancel the handler even if disconnect wins the race. + """Buffered uploads skip handler dispatch on disconnect (ASGI 2.4 path). This exercises the ASGI 2.4 path where the response must watch ``receive()`` directly because Starlette does not listen for disconnects - while streaming the response body. + while streaming the response body. The disconnect watcher fires the + ``on_disconnect`` callback before the upload handler is enqueued, so + ``enqueue_stream_delta`` is never called. Args: token: A token. @@ -1338,17 +1338,8 @@ async def form(): # noqa: RUF029 request_mock.form = form - cancelled = asyncio.Event() - task_future = Mock() - task_future.done = Mock(side_effect=cancelled.is_set) - task_future.cancel = Mock(side_effect=cancelled.set) - - async def enqueue_stream_delta(_token, _event, on_task_future=None): - assert on_task_future is not None - on_task_future(task_future) - await cancelled.wait() - if False: # pragma: no cover - yield {} + msg = "upload handler should not be enqueued" + enqueue_stream_delta = Mock(side_effect=AssertionError(msg)) app = Mock( event_processor=Mock(enqueue_stream_delta=enqueue_stream_delta), @@ -1375,7 +1366,7 @@ async def send(_message): # noqa: RUF029 timeout=1, ) - assert task_future.cancel.call_count == 1 + assert enqueue_stream_delta.call_count == 0 assert form_close.await_count == 1 assert bio.closed @@ -1437,12 +1428,11 @@ async def send(message): err = "client disconnected" raise OSError(err) - with pytest.raises(ClientDisconnect): - await streaming_response( - asgi_24_scope, - receive, - send, - ) + await streaming_response( + asgi_24_scope, + receive, + send, + ) assert enqueue_stream_delta.call_count == 0 assert form_close.await_count == 1 From d012632e73fd56155e88c9d7bbbc89fa892c734c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 14 Apr 2026 23:29:37 +0500 Subject: [PATCH 30/31] fix: simplify DisconnectAwareStreamingResponse ASGI 2.4 path Remove manual disconnect watching and task cancellation in favor of simply awaiting stream_response directly. The body iterator is now always closed in the finally block regardless of disconnect. --- .../reflex_base/utils/streaming_response.py | 45 +------ tests/units/test_app.py | 116 ++++-------------- tests/units/utils/test_streaming_response.py | 40 +----- 3 files changed, 34 insertions(+), 167 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/streaming_response.py b/packages/reflex-base/src/reflex_base/utils/streaming_response.py index 70af5f805dd..66c6ab7fc62 100644 --- a/packages/reflex-base/src/reflex_base/utils/streaming_response.py +++ b/packages/reflex-base/src/reflex_base/utils/streaming_response.py @@ -59,7 +59,7 @@ def _collapse_excgroups() -> Generator[None, None, None]: class DisconnectAwareStreamingResponse(StreamingResponse): - """Streaming response that cancels its body task on disconnect.""" + """Streaming response with a guaranteed finish callback.""" _on_finish: Callable[[], Awaitable[None]] _on_disconnect: Callable[[], None] | None @@ -82,21 +82,12 @@ def __init__( super().__init__(*args, **kwargs) self._on_finish = on_finish self._on_disconnect = on_disconnect - self._disconnected = False def _notify_disconnect(self) -> None: """Invoke the on_disconnect callback if one was provided.""" - self._disconnected = True if self._on_disconnect is not None: self._on_disconnect() - async def _watch_disconnect(self, receive: Receive) -> None: - """Wait for the client connection to close.""" - while True: - message = await receive() - if message["type"] == "http.disconnect": - return - async def _close_body_iterator(self) -> None: """Close the body iterator if it supports ``aclose``.""" aclose = getattr(self.body_iterator, "aclose", None) @@ -104,7 +95,7 @@ async def _close_body_iterator(self) -> None: await aclose() async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - """Serve the response and cancel the body task on disconnect.""" + """Serve the response and always run the finish callback.""" spec_version = _parse_asgi_spec_version(scope) try: @@ -128,39 +119,13 @@ async def _disconnect_then_notify() -> None: else: await wrap(partial(self.listen_for_disconnect, receive)) else: - # ASGI >= 2.4: Starlette's StreamingResponse.__call__ - # delegates straight to stream_response(send) without - # reading from receive(). We still need a disconnect - # watcher so that the on_disconnect callback fires. - # Use the same anyio task-group pattern as the < 2.4 - # path to avoid asyncio.wait race conditions. try: - with _collapse_excgroups(): - async with anyio.create_task_group() as task_group: - - async def wrap( - func: Callable[[], Awaitable[None]], - ) -> None: - await func() - task_group.cancel_scope.cancel() - - task_group.start_soon( - wrap, partial(self.stream_response, send) - ) - - async def _disconnect_then_notify() -> None: - await self._watch_disconnect(receive) - self._notify_disconnect() - - await wrap(_disconnect_then_notify) + await self.stream_response(send) except OSError as err: - await self._close_body_iterator() + self._notify_disconnect() raise ClientDisconnect from err - # anyio cancellation does not call aclose() on the body - # async generator, so close it explicitly on disconnect. - if self._disconnected: - await self._close_body_iterator() finally: + await self._close_body_iterator() await self._on_finish() if self.background is not None: diff --git a/tests/units/test_app.py b/tests/units/test_app.py index fd71258239c..138f9aad047 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -30,6 +30,7 @@ from reflex_components_radix.themes.typography.text import Text from starlette.applications import Starlette from starlette.datastructures import FormData, Headers, UploadFile +from starlette.requests import ClientDisconnect from starlette.responses import StreamingResponse from starlette_admin.auth import AuthProvider @@ -1295,90 +1296,22 @@ async def send(_message): assert form_close.await_count == 0 assert not bio.closed - await streaming_response( - {"type": "http", "asgi": {"spec_version": "2.4"}}, - receive, - send, - ) - - assert form_close.await_count == 1 - assert bio.closed - - -@pytest.mark.asyncio -async def test_upload_file_skips_handler_on_disconnect_asgi24( - token: str, -): - """Buffered uploads skip handler dispatch on disconnect (ASGI 2.4 path). - - This exercises the ASGI 2.4 path where the response must watch - ``receive()`` directly because Starlette does not listen for disconnects - while streaming the response body. The disconnect watcher fires the - ``on_disconnect`` callback before the upload handler is enqueued, so - ``enqueue_stream_delta`` is never called. - - Args: - token: A token. - """ - request_mock = unittest.mock.Mock() - request_mock.headers = { - "reflex-client-token": token, - "reflex-event-handler": f"{FileUploadState.get_full_name()}.multi_handle_upload", - } - - bio = io.BytesIO(b"contents of image one") - file1 = UploadFile(filename="image1.jpg", file=bio) - form_data = FormData([("files", file1)]) - original_close = form_data.close - form_close = AsyncMock(side_effect=original_close) - form_data.close = form_close - - async def form(): # noqa: RUF029 - return form_data - - request_mock.form = form - - msg = "upload handler should not be enqueued" - enqueue_stream_delta = Mock(side_effect=AssertionError(msg)) - - app = Mock( - event_processor=Mock(enqueue_stream_delta=enqueue_stream_delta), - ) - - upload_fn = upload(app) - streaming_response = await upload_fn(request_mock) - - assert isinstance(streaming_response, StreamingResponse) - - async def receive(): - await asyncio.sleep(0) - return {"type": "http.disconnect"} - - async def send(_message): # noqa: RUF029 - return None - - await asyncio.wait_for( - streaming_response( + with pytest.raises(asyncio.CancelledError): + await streaming_response( {"type": "http", "asgi": {"spec_version": "2.4"}}, receive, send, - ), - timeout=1, - ) + ) - assert enqueue_stream_delta.call_count == 0 assert form_close.await_count == 1 assert bio.closed @pytest.mark.asyncio -async def test_upload_file_skips_buffered_handler_when_disconnect_detected_on_probe( +async def test_upload_file_raises_client_disconnect_when_stream_send_fails( token: str, ): - """Buffered uploads skip handler dispatch when the probe send disconnects. - - This models ASGI 2.4+ behavior where the upload request can finish parsing, - but the client disconnect is only surfaced on the first response-body send. + """Buffered uploads close the handler stream when send raises OSError. Args: token: A token. @@ -1401,10 +1334,14 @@ async def form(): # noqa: RUF029 request_mock.form = form - msg = "upload handler should not be enqueued" - probe_chunk = b"\n" - asgi_24_scope = {"type": "http", "asgi": {"spec_version": "2.4"}} - enqueue_stream_delta = Mock(side_effect=AssertionError(msg)) + stream_closed = asyncio.Event() + + async def enqueue_stream_delta(_token, _event, on_task_future=None): + try: + yield {"state": {"ok": True}} + await asyncio.Event().wait() + finally: + stream_closed.set() app = Mock( event_processor=Mock(enqueue_stream_delta=enqueue_stream_delta), @@ -1415,26 +1352,25 @@ async def form(): # noqa: RUF029 assert isinstance(streaming_response, StreamingResponse) - async def receive(): - await asyncio.sleep(0) - return {"type": "http.disconnect"} + async def receive() -> dict[str, Any]: + await asyncio.Event().wait() + msg = "receive should not return" + raise AssertionError(msg) async def send(message): await asyncio.sleep(0) - if ( - message.get("type") == "http.response.body" - and message.get("body") == probe_chunk - ): + if message.get("type") == "http.response.body": err = "client disconnected" raise OSError(err) - await streaming_response( - asgi_24_scope, - receive, - send, - ) + with pytest.raises(ClientDisconnect): + await streaming_response( + {"type": "http", "asgi": {"spec_version": "2.4"}}, + receive, + send, + ) - assert enqueue_stream_delta.call_count == 0 + await asyncio.wait_for(stream_closed.wait(), timeout=1) assert form_close.await_count == 1 assert bio.closed diff --git a/tests/units/utils/test_streaming_response.py b/tests/units/utils/test_streaming_response.py index 122af46a9b7..9ee09a7801c 100644 --- a/tests/units/utils/test_streaming_response.py +++ b/tests/units/utils/test_streaming_response.py @@ -9,47 +9,11 @@ from starlette.requests import ClientDisconnect -@pytest.mark.asyncio -async def test_disconnect_cancels_stream_task_and_runs_finish(): - """A receive-side disconnect cancels the body stream and cleanup runs once.""" - body_closed = asyncio.Event() - body_started = asyncio.Event() - on_finish = AsyncMock() - - async def body(): - try: - body_started.set() - yield b"payload" - await asyncio.Event().wait() - finally: - body_closed.set() - - async def receive(): - await body_started.wait() - return {"type": "http.disconnect"} - - async def send(_message): - await asyncio.sleep(0) - - response = DisconnectAwareStreamingResponse( - body(), - media_type="application/x-ndjson", - on_finish=on_finish, - ) - - await asyncio.wait_for( - response({"type": "http", "asgi": {"spec_version": "2.4"}}, receive, send), - timeout=1, - ) - - await asyncio.wait_for(body_closed.wait(), timeout=1) - on_finish.assert_awaited_once() - - @pytest.mark.asyncio async def test_send_oserror_raises_client_disconnect_and_closes_body(): """A send-side disconnect still raises ClientDisconnect and closes the stream.""" body_closed = asyncio.Event() + disconnect_notified = asyncio.Event() on_finish = AsyncMock() async def body(): @@ -74,12 +38,14 @@ async def send(message): body(), media_type="application/x-ndjson", on_finish=on_finish, + on_disconnect=disconnect_notified.set, ) with pytest.raises(ClientDisconnect): await response({"type": "http", "asgi": {"spec_version": "2.4"}}, receive, send) await asyncio.wait_for(body_closed.wait(), timeout=1) + assert disconnect_notified.is_set() on_finish.assert_awaited_once() From 99aad2c21a6b71e952c3be3092cc27c77318be93 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 15 Apr 2026 01:21:03 +0500 Subject: [PATCH 31/31] pyi hashes --- pyi_hashes.json | 102 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/pyi_hashes.json b/pyi_hashes.json index 9df886c0016..88eb9573581 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,21 +1,123 @@ { + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "a879ccd253e901964a7ab7ea7154f904", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "d3e0c33fdc34f5c154ac387d550c0d29", + "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", + "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "ecccfd8a9b0e8b2f4128ff13ff27a9da", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "2535814d409e5feaf57da63dcf0abeaf", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "a2e67a9814dc61853ca2299d9d9c698d", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "59170074a1a228ce58685f3f207954f2", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "e4cbfc46eabb904596be4372392add35", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "629a483c570b04ca3d83ecdc53914770", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "0cfa2d8c52321ce7440e887d03007d5b", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "bfc7fb609b822f597d1141595f8090fe", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", + "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "dd221754c5e076a3a833c8584da72dc5", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6", "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "ac589d6237fe51414d536b9d70de5dec", "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6", + "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", + "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "ff68d843c5987d3f0d773a6367eb9c63", + "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "d2500a39e6e532bb90c83438343905bf", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "ca840a20c8e1c1f5335fb815a25b6c32", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "c38a432d1fd0c3208c4fc3a546c67e4d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "b794f4f4f7ad17c6939d5526b9c63397", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "f9e51feebda79fb063bc264a235df0c3", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "c86abf00384b5f15725a0daf2533848d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "222176bffc14191018fd0e3af3741aff", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "fbbe0bf222d4196c32c88d05cb077997", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "cba93678248925c981935a251379aa7c", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "4abedc6f98f6d54194ff9e7f1f76314e", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "07de150d57e16f66b62d66a94da98d74", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "3892ce64fef33649813a25f63c0ba43b", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "4d6121ccc963c64e33c49acd4295eb7a", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "b3b66ec57525c53ea741897e2bc8370e", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "c86bc8d4604e3d8c8d40baad2ac6dc17", + "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "e75cbf2a34620721432b1556f3c875cd", "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ed020269e4728cc6abe72354193146b7", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "d5e0419729df4ddf2caf214f40ae7845", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "613abb9870259547c99eb434a3a17512", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "1671e796449b236386d8f53d33e42b2f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "9fde9929ca5197e0e1880bce9a08e926", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "e5d6387a93c74dafaa0d6f1719e08bac", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "09487ef45cf26edb0b7c1d6da5f097f0", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "76afb58340c6be1f26b7b110473efa55", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "6cbf013e21d7280118dfd7383998b3bf", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "63b4134246f68f9f556896d6ce194462", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "ec3f89e7d187303344d4127a83522b22", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "99541ee46f112eb4096f903a99f5ffb8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "1dfd91741ff402b3ed93b6daca4939f3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "ed9198da4a7950a8579e50ad970c34ef", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "15f9cee0584414f2d2e0fb82c167f216", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "3f328bb0ba5225e4478febf8c7623833", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "be8eed28e19221a406e554829809ff0d", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "9b2adf18f7d239b8e7431f39042ed301", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "4d5813a47b8f8b6ac317ca01d87d9afb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "1ab01f45a4c5ef4211eacc00cc99e4a5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "38a7412205a98617f98218a5b213ada1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "d84b16ac16083a534199fd23659aaa06", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "51fda6313f1ce86d5b1ffdfd68ae8b74", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "bba40e5eae75314157378c9e8b0eea73", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "bf9f751a701137bfedc254657d4c5be4", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "605479e11d19dd7730c90125b198c9b6", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "519781d33b99c675a12014d400e54d08", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "f4848f7d89abb4c78f6db52c624cdabf", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "61a08374fa19a0bb3f52b8654effc0f2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "530c51742031389d4b2ae43548ff0f03", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "23b21bc11a0012e13ce9bb79b47ba146", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "8364f40600870bafa585528d9cadedf8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "3a52910c327f55656eb59309f9362361", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "fcf562b2f61ecdcc2de6f70d2ebf9907", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "250b7e77b67e7d8cd3fff2b40526c04c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "799acce0af81899a3a310bdcd43c403b", "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "9e452af27229b676ad0146e40f75bed5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "5b262189e235cac17182e79188b1681a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "e06c8fd64132765d61b9edb87a48558b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "5aa934d7c6ba3889fa943eabee7dc05f", "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "c67fafd1aec105cb5a9927ff0e6d2071", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "aa68061a8e5dfd4adf336d1d1cb000fb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "06b92d31331c6f08b5083fcc811b754a", "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "e7cd3a9cea1c34e21f731f1bd05c1ceb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "8c968fead3155b2d51c687459811b5df", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "cfc8a927642e5b68feabc80080aeb8dc", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "cf88cf870eefaacaf765ead10fb4593b", "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "92d5a2df77a69a28a4d591000ee46bd1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "8a1e4376cadf4961212d39a5128a0e4f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "34c7ed3fe1e5f702a98d72751b0052fa", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "619a9d8351748fffe76136002931e583", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "4919daa4483b7c12f6fafd02a2275e0f", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "0817c9232a6e4790cff8ea8aa6001950", "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "6c1c26149d57c708fab04b82de0eb515", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "75207a9fe4f37ec2a2f1becbbbd5237b", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "277bbf09d72e0c450241f0b7d39ebb60", "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "be20d1d71c3b16f7e973a0329c3d81d6", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "c051ab3a26c23107043e203b060e1412", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "234407dbd466bf9c87d75ce979ab0e2d", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "792f2ffe75f3acce94af31bd8458a061"