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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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" From 2b32ba2ca7fc3c75ea060abc2fe1ae16fce17bfb Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 16 Apr 2026 20:58:09 +0500 Subject: [PATCH 32/59] feat: decouple Radix Themes into an opt-in compiler plugin Extract Radix Themes integration from the core compiler into RadixThemesPlugin. Apps using only rx.el.* no longer load the Radix CSS bundle or JS imports. - Auto-detect Radix components and enable implicitly with deprecation warning - Support legacy App(theme=...) with deprecation path - Explicit RadixThemesPlugin in rxconfig takes precedence - Tailwind plugins conditionally include Radix stylesheet - ColorModeContext gets safe default value for non-Radix apps --- .../src/reflex_base/compiler/templates.py | 7 +- .../src/reflex_base/components/dynamic.py | 12 +- .../src/reflex_base/plugins/base.py | 1 + .../src/reflex_base/plugins/tailwind_v3.py | 48 ++- .../src/reflex_base/plugins/tailwind_v4.py | 51 ++- .../src/reflex_components_radix/plugin.py | 121 ++++++ reflex/app.py | 9 +- reflex/compiler/compiler.py | 373 ++++++++++-------- reflex/compiler/plugins/builtin.py | 13 +- reflex/plugins/__init__.py | 2 + tests/units/compiler/test_compiler.py | 74 +++- tests/units/plugins/test_tailwind.py | 41 ++ tests/units/test_app.py | 122 ++++++ 13 files changed, 661 insertions(+), 213 deletions(-) create mode 100644 packages/reflex-components-radix/src/reflex_components_radix/plugin.py create mode 100644 tests/units/plugins/test_tailwind.py diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 9091b8edfc5..a57e852ce96 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -348,7 +348,12 @@ def context_template( export const initialState = {"{}" if not initial_state else json_dumps(initial_state)} export const defaultColorMode = {default_color_mode} -export const ColorModeContext = createContext(null); +export const ColorModeContext = createContext({{ + colorMode: defaultColorMode, + resolvedColorMode: defaultColorMode === "dark" ? "dark" : "light", + toggleColorMode: () => {{}}, + setColorMode: () => {{}}, +}}); export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const StateContexts = {{{state_contexts_str}}}; diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 0386167198d..6fdfc911baa 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -26,14 +26,20 @@ def get_cdn_url(lib: str) -> str: return f"https://cdn.jsdelivr.net/npm/{lib}" + "/+esm" -bundled_libraries = [ +DEFAULT_BUNDLED_LIBRARIES = [ "react", - "@radix-ui/themes", "@emotion/react", f"$/{constants.Dirs.UTILS}/context", f"$/{constants.Dirs.UTILS}/state", f"$/{constants.Dirs.UTILS}/components", ] +bundled_libraries = list(DEFAULT_BUNDLED_LIBRARIES) + + +def reset_bundled_libraries() -> None: + """Reset the bundled library registry to its default values.""" + bundled_libraries.clear() + bundled_libraries.extend(DEFAULT_BUNDLED_LIBRARIES) def bundle_library(component: Union["Component", str]): @@ -46,7 +52,7 @@ def bundle_library(component: Union["Component", str]): DynamicComponentMissingLibraryError: Raised when a dynamic component is missing a library. """ if isinstance(component, str): - bundled_libraries.append(component) + bundled_libraries.append(format_library_name(component)) return if component.library is None: msg = "Component must have a library to bundle." diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index fdd8911a7f5..cd3c6a580f2 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -43,6 +43,7 @@ class PreCompileContext(CommonContext): add_save_task: AddTaskProtocol add_modify_task: Callable[[str, Callable[[str], str]], None] + radix_themes_plugin: Any unevaluated_pages: Sequence["UnevaluatedPage"] diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py index d67264fc0e3..66f575db5c2 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py @@ -29,7 +29,7 @@ class Constants(SimpleNamespace): ROOT_STYLE_CONTENT = """ @import "tailwindcss/base"; -@import url('{radix_url}'); +{radix_import} @tailwind components; @tailwind utilities; @@ -54,9 +54,12 @@ def compile_config(config: TailwindConfig): ) -def compile_root_style(): +def compile_root_style(include_radix_themes: bool = True): """Compile the Tailwind root style. + Args: + include_radix_themes: Whether to include the Radix stylesheet import. + Returns: The compiled Tailwind root style. """ @@ -65,7 +68,9 @@ def compile_root_style(): return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH ), Constants.ROOT_STYLE_CONTENT.format( - radix_url=RADIX_THEMES_STYLESHEET, + radix_import=( + f"@import url('{RADIX_THEMES_STYLESHEET}');" if include_radix_themes else "" + ), ) @@ -112,11 +117,14 @@ def add_tailwind_to_postcss_config(postcss_file_content: str) -> str: return "\n".join(postcss_file_lines) -def add_tailwind_to_css_file(css_file_content: str) -> str: +def add_tailwind_to_css_file( + css_file_content: str, *, include_radix_themes: bool = True +) -> str: """Add tailwind to the css file. Args: css_file_content: The content of the css file. + include_radix_themes: Whether the root stylesheet already imports Radix. Returns: The modified css file content. @@ -125,16 +133,23 @@ def add_tailwind_to_css_file(css_file_content: str) -> str: if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content - if RADIX_THEMES_STYLESHEET not in css_file_content: - print( # noqa: T201 - f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " - "Please make sure the file exists and is valid." + if include_radix_themes and RADIX_THEMES_STYLESHEET in css_file_content: + return css_file_content.replace( + f"@import url('{RADIX_THEMES_STYLESHEET}');", + Constants.TAILWIND_CSS, ) - return css_file_content - return css_file_content.replace( - f"@import url('{RADIX_THEMES_STYLESHEET}');", - Constants.TAILWIND_CSS, + + lines = css_file_content.splitlines() + insert_at = next( + ( + index + 1 + for index, line in enumerate(lines) + if "__reflex_style_reset.css" in line + ), + 1, ) + lines.insert(insert_at, Constants.TAILWIND_CSS) + return "\n".join(lines) @dataclasses.dataclass @@ -162,9 +177,14 @@ def pre_compile(self, **context): context: The context for the plugin. """ context["add_save_task"](compile_config, self.get_unversioned_config()) - context["add_save_task"](compile_root_style) + include_radix_themes = context["radix_themes_plugin"].enabled + + context["add_save_task"](compile_root_style, include_radix_themes) context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) context["add_modify_task"]( str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), - add_tailwind_to_css_file, + lambda content: add_tailwind_to_css_file( + content, + include_radix_themes=include_radix_themes, + ), ) diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py index 4ae637752a1..2382c38244c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py @@ -29,8 +29,7 @@ class Constants(SimpleNamespace): ROOT_STYLE_CONTENT = """@layer theme, base, components, utilities; @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/preflight.css" layer(base); -@import "{radix_url}" layer(components); -@import "tailwindcss/utilities.css" layer(utilities); +{radix_import}@import "tailwindcss/utilities.css" layer(utilities); @config "../tailwind.config.js"; """ @@ -53,9 +52,12 @@ def compile_config(config: TailwindConfig): ) -def compile_root_style(): +def compile_root_style(include_radix_themes: bool = True): """Compile the Tailwind root style. + Args: + include_radix_themes: Whether to include the Radix stylesheet import. + Returns: The compiled Tailwind root style. """ @@ -64,7 +66,11 @@ def compile_root_style(): return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH ), Constants.ROOT_STYLE_CONTENT.format( - radix_url=RADIX_THEMES_STYLESHEET, + radix_import=( + f'@import "{RADIX_THEMES_STYLESHEET}" layer(components);\n' + if include_radix_themes + else "" + ), ) @@ -115,11 +121,14 @@ def add_tailwind_to_postcss_config(postcss_file_content: str) -> str: return "\n".join(postcss_file_lines) -def add_tailwind_to_css_file(css_file_content: str) -> str: +def add_tailwind_to_css_file( + css_file_content: str, *, include_radix_themes: bool = True +) -> str: """Add tailwind to the css file. Args: css_file_content: The content of the css file. + include_radix_themes: Whether the root stylesheet already imports Radix. Returns: The modified css file content. @@ -128,16 +137,23 @@ def add_tailwind_to_css_file(css_file_content: str) -> str: if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content - if RADIX_THEMES_STYLESHEET not in css_file_content: - print( # noqa: T201 - f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " - "Please make sure the file exists and is valid." + if include_radix_themes and RADIX_THEMES_STYLESHEET in css_file_content: + return css_file_content.replace( + f"@import url('{RADIX_THEMES_STYLESHEET}');", + Constants.TAILWIND_CSS, ) - return css_file_content - return css_file_content.replace( - f"@import url('{RADIX_THEMES_STYLESHEET}');", - Constants.TAILWIND_CSS, + + lines = css_file_content.splitlines() + insert_at = next( + ( + index + 1 + for index, line in enumerate(lines) + if "__reflex_style_reset.css" in line + ), + 1, ) + lines.insert(insert_at, Constants.TAILWIND_CSS) + return "\n".join(lines) @dataclasses.dataclass @@ -166,9 +182,14 @@ def pre_compile(self, **context): context: The context for the plugin. """ context["add_save_task"](compile_config, self.get_unversioned_config()) - context["add_save_task"](compile_root_style) + include_radix_themes = context["radix_themes_plugin"].enabled + + context["add_save_task"](compile_root_style, include_radix_themes) context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) context["add_modify_task"]( str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), - add_tailwind_to_css_file, + lambda content: add_tailwind_to_css_file( + content, + include_radix_themes=include_radix_themes, + ), ) diff --git a/packages/reflex-components-radix/src/reflex_components_radix/plugin.py b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py new file mode 100644 index 00000000000..f7367a9b486 --- /dev/null +++ b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py @@ -0,0 +1,121 @@ +"""Plugin support for opt-in Radix Themes integration.""" + +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING, Any + +from reflex_base.components.component import BaseComponent, Component +from reflex_base.components.dynamic import bundle_library +from reflex_base.plugins.base import Plugin +from reflex_base.utils import console + +from reflex_components_radix import themes +from reflex_components_radix.themes.base import RadixThemesComponent + +if TYPE_CHECKING: + from reflex_base.plugins.compiler import PageContext + + +RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" +RADIX_THEMES_PACKAGE = "@radix-ui/themes@3.3.0" +_DEPRECATION_VERSION = "0.9.0" +_REMOVAL_VERSION = "1.0" + + +@dataclasses.dataclass +class RadixThemesPlugin(Plugin): + """Opt-in plugin for Radix Themes assets and app-level wrapping.""" + + theme: Component | None = dataclasses.field( + default_factory=lambda: themes.theme(accent_color="blue") + ) + enabled: bool = dataclasses.field(default=True, repr=False) + _explicit: bool = dataclasses.field(default=True, repr=False) + _app_theme_warning_emitted: bool = dataclasses.field( + default=False, init=False, repr=False + ) + + @classmethod + def create_implicit(cls) -> RadixThemesPlugin: + """Create a compile-local plugin that starts disabled. + + Returns: + The disabled compile-local plugin. + """ + return cls(enabled=False, _explicit=False) + + def get_stylesheet_paths(self, **context: Any) -> tuple[str, ...]: + """Return the Radix Themes stylesheet when enabled.""" + del context + return (RADIX_THEMES_STYLESHEET,) if self.enabled else () + + def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]: + """Return the Radix Themes package when enabled.""" + del context + return (RADIX_THEMES_PACKAGE,) if self.enabled else () + + def enter_component( + self, + comp: BaseComponent, + /, + *, + page_context: PageContext, + compile_context: Any, + in_prop_tree: bool = False, + ) -> None: + """Auto-enable the plugin when a Radix Themes component is compiled.""" + del page_context, compile_context, in_prop_tree + + if self.enabled or not isinstance(comp, RadixThemesComponent): + return + + self.enabled = True + bundle_library(RADIX_THEMES_PACKAGE) + if not self._explicit and not self._app_theme_warning_emitted: + console.deprecate( + feature_name="Implicit Radix Themes enablement", + reason=( + "a Radix Themes component was detected, which enables the full " + "Radix CSS bundle. Configure `rx.plugins.RadixThemesPlugin()` in " + "`rxconfig.py` to make this explicit, or remove Radix components " + "to avoid loading the stylesheet" + ), + deprecation_version=_DEPRECATION_VERSION, + removal_version=_REMOVAL_VERSION, + ) + + def compile_page( + self, + page_ctx: PageContext, + /, + **kwargs: Any, + ) -> None: + """Inject the app-level theme wrapper when Radix Themes is active.""" + del kwargs + + if self.enabled and self.theme is not None: + page_ctx.app_wrap_components[20, "Theme"] = self.theme + + def get_theme(self) -> Component | None: + """Return the effective theme component for the active compile.""" + return self.theme if self.enabled else None + + def apply_app_theme(self, theme: Component) -> None: + """Handle deprecated ``App(theme=...)`` compatibility.""" + console.deprecate( + feature_name="App(theme=...)", + reason=( + "configure `rx.plugins.RadixThemesPlugin(theme=...)` in " + "`rxconfig.py` instead" + ), + deprecation_version=_DEPRECATION_VERSION, + removal_version=_REMOVAL_VERSION, + ) + self._app_theme_warning_emitted = True + + if self._explicit: + return + + self.enabled = True + self.theme = theme diff --git a/reflex/app.py b/reflex/app.py index cc6bef7b99d..a73bf09eab0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -51,7 +51,6 @@ ) from reflex_components_core.core.breakpoints import set_breakpoints from reflex_components_core.core.sticky import sticky -from reflex_components_radix import themes from reflex_components_sonner.toast import toast from socketio import ASGIApp as EngineIOApp from socketio import AsyncNamespace, AsyncServer @@ -273,13 +272,13 @@ class App(MiddlewareMixin, LifespanMixin): app = rx.App( # Set global level style. style={...}, - # Set the top level theme. + # Deprecated legacy shortcut for the Radix Themes plugin. theme=rx.theme(accent_color="blue"), ) ``` Attributes: - theme: The global [theme](https://reflex.dev/docs/styling/theming/#theme) for the entire app. + theme: Deprecated legacy shortcut for configuring the app-level Radix theme. 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. @@ -297,9 +296,7 @@ class App(MiddlewareMixin, LifespanMixin): api_transformer: Transform the ASGI app before running it. """ - theme: Component | None = dataclasses.field( - default_factory=lambda: themes.theme(accent_color="blue") - ) + theme: Component | None = dataclasses.field(default=None) style: ComponentStyle = dataclasses.field(default_factory=dict) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index de941cd4a0e..03b113122a7 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -22,7 +22,7 @@ from reflex_base.constants.compiler import PageNames, ResetStylesheet from reflex_base.constants.state import FIELD_MARKER from reflex_base.environment import environment -from reflex_base.plugins import CompileContext, CompilerHooks, PageContext +from reflex_base.plugins import CompileContext, CompilerHooks, PageContext, Plugin from reflex_base.style import SYSTEM_COLOR_MODE from reflex_base.utils.exceptions import ReflexError from reflex_base.utils.format import to_title_case @@ -30,6 +30,7 @@ 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 +from reflex_components_radix.plugin import RadixThemesPlugin from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from reflex.compiler import templates, utils @@ -45,6 +46,8 @@ from reflex.utils.exec import get_compile_context, is_prod_mode from reflex.utils.prerequisites import get_web_dir +RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" + def _set_progress_total( progress: Progress | console.PoorProgress, @@ -191,20 +194,23 @@ def _compile_page(component: BaseComponent) -> str: def compile_root_stylesheet( - stylesheets: list[str], reset_style: bool = True + stylesheets: list[str], + reset_style: bool = True, + plugins: Sequence[Plugin] | None = None, ) -> tuple[str, str]: """Compile the root stylesheet. Args: stylesheets: The stylesheets to include in the root stylesheet. reset_style: Whether to include CSS reset for margin and padding. + plugins: The effective plugins for the active compile. Returns: The path and code of the compiled root stylesheet. """ output_path = utils.get_root_stylesheet_path() - code = _compile_root_stylesheet(stylesheets, reset_style) + code = _compile_root_stylesheet(stylesheets, reset_style, plugins) return output_path, code @@ -244,15 +250,17 @@ def _validate_stylesheet(stylesheet_full_path: Path, assets_app_path: Path) -> N raise ValueError(msg) -RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" - - -def _compile_root_stylesheet(stylesheets: list[str], reset_style: bool = True) -> str: +def _compile_root_stylesheet( + stylesheets: list[str], + reset_style: bool = True, + plugins: Sequence[Plugin] | None = None, +) -> str: """Compile the root stylesheet. Args: stylesheets: The stylesheets to include in the root stylesheet. reset_style: Whether to include CSS reset for margin and padding. + plugins: The effective plugins for the active compile. Returns: The compiled root stylesheet. @@ -268,14 +276,10 @@ def _compile_root_stylesheet(stylesheets: list[str], reset_style: bool = True) - # Reference the vendored style reset file (automatically copied from .templates/web) sheets.append(f"./{ResetStylesheet.FILENAME}") - sheets.extend( - [RADIX_THEMES_STYLESHEET] - + [ - sheet - for plugin in get_config().plugins - for sheet in plugin.get_stylesheet_paths() - ] - ) + active_plugins = get_config().plugins if plugins is None else plugins + sheets.extend([ + sheet for plugin in active_plugins for sheet in plugin.get_stylesheet_paths() + ]) failed_to_import_sass = False assets_app_path = Path.cwd() / constants.Dirs.APP_ASSETS @@ -826,9 +830,6 @@ def _resolve_app_wrap_components( } 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 @@ -852,6 +853,32 @@ def memoized_toast_provider(): return app_wrappers +def _resolve_radix_themes_plugin( + app: App, + plugins: Sequence[Plugin], +) -> tuple[tuple[Plugin, ...], RadixThemesPlugin]: + """Resolve the effective Radix Themes plugin for the active compile. + + Returns: + The compiler plugin chain and the effective Radix Themes plugin. + """ + explicit_plugin = next( + (plugin for plugin in plugins if isinstance(plugin, RadixThemesPlugin)), + None, + ) + if explicit_plugin is not None: + radix_plugin = explicit_plugin + plugin_chain = tuple(plugins) + else: + radix_plugin = RadixThemesPlugin.create_implicit() + plugin_chain = (*plugins, radix_plugin) + + if app.theme is not None: + radix_plugin.apply_app_theme(app.theme) + + return plugin_chain, radix_plugin + + def compile_app( app: App, *, @@ -860,6 +887,7 @@ def compile_app( use_rich: bool = True, ) -> None: """Compile an app using the compiler plugin pipeline.""" + from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries from reflex_base.utils.exceptions import ReflexRuntimeError app._apply_decorated_pages() @@ -904,179 +932,200 @@ def compile_app( else console.PoorProgress() ) fixed_steps = 7 + compiler_plugins, radix_themes_plugin = _resolve_radix_themes_plugin( + app, + config.plugins, + ) + reset_bundled_libraries() + for plugin in compiler_plugins: + for dependency in plugin.get_frontend_dependencies(): + bundle_library(dependency) 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( - evaluate_progress=lambda: progress.advance(task), - render_progress=lambda: progress.advance(task), + try: + compile_ctx = CompileContext( + app=app, + pages=list(app._unevaluated_pages.values()), + hooks=CompilerHooks( + plugins=default_page_plugins(style=app.style, plugins=compiler_plugins) + ), ) - 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." + with console.timing("Compile pages"), compile_ctx: + compile_ctx.compile( + evaluate_progress=lambda: progress.advance(task), + render_progress=lambda: progress.advance(task), ) - 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 - if is_prod_mode() and config.show_built_with_reflex: - app._setup_sticky_badge() + 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 - progress.advance(task) + app._stateful_pages.update(compile_ctx.stateful_routes) + app._write_stateful_pages_marker() + app._add_optional_endpoints() + app._validate_var_dependencies() - 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 + 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 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." + if is_prod_mode() and config.show_built_with_reflex: + app._setup_sticky_badge() + + progress.advance(task) + + 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 + + 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) + progress.advance(task) + + app_wrappers = _resolve_app_wrap_components( + app, compile_ctx.app_wrap_components ) - raise ReflexRuntimeError(msg) - 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()) - - ( - memo_components_output, - memo_components_result, - memo_components_imports, - ) = compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), + app_root = app._app_root(app_wrappers) + all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) + ( - *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) - progress.advance(task) - - 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} + memo_components_output, + memo_components_result, + memo_components_imports, + ) = compile_memo_components( + dict.fromkeys(CUSTOM_COMPONENTS.values()), + ( + *tuple(EXPERIMENTAL_MEMOS.values()), + *tuple(compile_ctx.auto_memo_components.values()), ), ) - ) - 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, - ) + compile_results.append((memo_components_output, memo_components_result)) + all_imports = utils.merge_imports(all_imports, memo_components_imports) + progress.advance(task) - 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]]] = [] + 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) - 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)) + 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, + ) - 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()), - ) + 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, + )), + radix_themes_plugin=radix_themes_plugin, + unevaluated_pages=list(app._unevaluated_pages.values()), + ) - if save_tasks: - _set_progress_total(progress, task, base_total + len(save_tasks)) + if save_tasks: + _set_progress_total(progress, task, base_total + len(save_tasks)) - progress.advance(task, advance=len(config.plugins)) + 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_root_stylesheet( + app.stylesheets, + app.reset_style, + plugins=compiler_plugins, + ) + ) + progress.advance(task) - compile_results.append(compile_theme(app.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: + 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) - 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_contexts(app._state, radix_themes_plugin.get_theme()) + ) + progress.advance(task) - compile_results.append(compile_app_root(app_root)) - progress.advance(task) + compile_results.append(compile_app_root(app_root)) + progress.advance(task) - progress.stop() + progress.stop() - if dry_run: - return + if dry_run: + return - with console.timing("Install Frontend Packages"): - app._get_frontend_packages(all_imports) + with console.timing("Install Frontend Packages"): + app._get_frontend_packages(all_imports) - frontend_skeleton.update_react_router_config( - prerender_routes=prerender_routes, - ) + frontend_skeleton.update_react_router_config( + prerender_routes=prerender_routes, + ) + finally: + reset_bundled_libraries() if is_prod_mode(): purge_web_pages_dir() diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 0fcfdcef647..78d23befd10 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -3,7 +3,7 @@ from __future__ import annotations import dataclasses -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any from reflex_base.components.component import BaseComponent, Component, ComponentStyle @@ -74,7 +74,6 @@ class ApplyStylePlugin(Plugin): _compiler_can_replace_enter_component = False style: ComponentStyle | None = None - theme: Component | None = None @staticmethod def _apply_style(comp: Component, style: ComponentStyle) -> None: @@ -410,16 +409,16 @@ def _collect_wrapper_subtree_into( def default_page_plugins( *, style: ComponentStyle | None = None, - theme: Component | None = None, + plugins: Sequence[Plugin] = (), ) -> tuple[Plugin, ...]: """Return the default compiler plugin ordering for page compilation.""" from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin - plugins: list[Plugin] = [DefaultPagePlugin()] + chain: list[Plugin] = [*plugins, DefaultPagePlugin()] if style is not None: - plugins.append(ApplyStylePlugin(style=style, theme=theme)) - plugins.extend((MemoizeStatefulPlugin(), DefaultCollectorPlugin())) - return tuple(plugins) + chain.append(ApplyStylePlugin(style=style)) + chain.extend((MemoizeStatefulPlugin(), DefaultCollectorPlugin())) + return tuple(chain) __all__ = [ diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py index 114646fd0b1..9bd4335ab02 100644 --- a/reflex/plugins/__init__.py +++ b/reflex/plugins/__init__.py @@ -17,6 +17,7 @@ tailwind_v3, tailwind_v4, ) +from reflex_components_radix.plugin import RadixThemesPlugin __all__ = [ "BaseContext", @@ -27,6 +28,7 @@ "PageContext", "Plugin", "PreCompileContext", + "RadixThemesPlugin", "SitemapPlugin", "TailwindV3Plugin", "TailwindV4Plugin", diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 218ae1de6d2..85e3d350ee1 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -5,6 +5,7 @@ import pytest from pytest_mock import MockerFixture from reflex_base import constants +from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries from reflex_base.constants.compiler import PageNames from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Var @@ -12,6 +13,7 @@ from reflex_components_core.base import document from reflex_components_core.el.elements.metadata import Link +import reflex as rx from reflex.compiler import compiler, utils @@ -162,7 +164,6 @@ def test_compile_stylesheets(tmp_path: Path, mocker: MockerFixture): ( "@layer __reflex_base;\n" "@import url('./__reflex_style_reset.css');\n" - "@import url('@radix-ui/themes/styles.css');\n" "@import url('https://fonts.googleapis.com/css?family=Sofia&effect=neon|outline|emboss|shadow-multiple');\n" "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css');\n" "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css');\n" @@ -226,7 +227,6 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture): ( "@layer __reflex_base;\n" "@import url('./__reflex_style_reset.css');\n" - "@import url('@radix-ui/themes/styles.css');\n" "@import url('./style.css');\n" f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}');\n" f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}');" @@ -248,7 +248,6 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture): ( "@layer __reflex_base;\n" "@import url('./__reflex_style_reset.css');\n" - "@import url('@radix-ui/themes/styles.css');\n" "@import url('./style.css');\n" f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}');\n" f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}');" @@ -295,7 +294,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker: MockerFixture): assert compiler.compile_root_stylesheet(stylesheets) == ( str(Path(".web") / "styles" / (PageNames.STYLESHEET_ROOT + ".css")), - "@layer __reflex_base;\n@import url('./__reflex_style_reset.css');\n@import url('@radix-ui/themes/styles.css');\n@import url('./style.css');", + "@layer __reflex_base;\n@import url('./__reflex_style_reset.css');\n@import url('./style.css');", ) @@ -334,10 +333,75 @@ def test_compile_stylesheets_no_reset(tmp_path: Path, mocker: MockerFixture): / "styles" / (PageNames.STYLESHEET_ROOT + ".css") ), - "@layer __reflex_base;\n@import url('@radix-ui/themes/styles.css');\n@import url('./style.css');", + "@layer __reflex_base;\n@import url('./style.css');", + ) + + +def test_compile_stylesheets_includes_radix_plugin( + tmp_path: Path, mocker: MockerFixture +): + """Explicit RadixThemesPlugin should add the Radix stylesheet import.""" + project = tmp_path / "test_project" + project.mkdir() + + assets_dir = project / "assets" + assets_dir.mkdir() + (assets_dir / "style.css").write_text(".root { color: red; }") + + config = mocker.Mock() + config.plugins = [rx.plugins.RadixThemesPlugin()] + mocker.patch("reflex.compiler.compiler.get_config", return_value=config) + mocker.patch("reflex.compiler.compiler.Path.cwd", return_value=project) + mocker.patch( + "reflex.compiler.compiler.get_web_dir", + return_value=project / constants.Dirs.WEB, + ) + mocker.patch( + "reflex.compiler.utils.get_web_dir", return_value=project / constants.Dirs.WEB + ) + + assert compiler.compile_root_stylesheet(["/style.css"]) == ( + str( + project + / constants.Dirs.WEB + / "styles" + / (PageNames.STYLESHEET_ROOT + ".css") + ), + "@layer __reflex_base;\n@import url('./__reflex_style_reset.css');\n@import url('@radix-ui/themes/styles.css');\n@import url('./style.css');", ) +def test_compile_app_root_omits_radix_window_library_by_default(): + """Apps without Radix should not import it in the app root.""" + reset_bundled_libraries() + + _, code = compiler.compile_app_root(rx.el.div("hello")) + + assert "@radix-ui/themes" not in code + + +def test_compile_app_root_includes_radix_window_library_when_bundled(): + """Bundled Radix libraries should be exposed to window.__reflex.""" + reset_bundled_libraries() + try: + bundle_library("@radix-ui/themes@3.3.0") + + _, code = compiler.compile_app_root(rx.el.div("hello")) + + assert 'import * as radix_ui_themes from "@radix-ui/themes";' in code + assert '"@radix-ui/themes": radix_ui_themes' in code + finally: + reset_bundled_libraries() + + +def test_compile_contexts_has_default_color_mode_context(): + """ColorModeContext should have a safe fallback value without Radix.""" + _, code = compiler.compile_contexts(None, None) + + assert "createContext({" in code + assert 'resolvedColorMode: defaultColorMode === "dark" ? "dark" : "light"' in code + + def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): """Test that an error is thrown for non-existent stylesheets. diff --git a/tests/units/plugins/test_tailwind.py b/tests/units/plugins/test_tailwind.py new file mode 100644 index 00000000000..3f40b960172 --- /dev/null +++ b/tests/units/plugins/test_tailwind.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pytest +from reflex_base.plugins import tailwind_v3, tailwind_v4 + + +@pytest.mark.parametrize("module", [tailwind_v3, tailwind_v4]) +def test_compile_root_style_omits_radix_when_disabled(module): + """Tailwind root styles should omit the Radix import when disabled.""" + _, code = module.compile_root_style(include_radix_themes=False) + + assert "@radix-ui/themes/styles.css" not in code + + +@pytest.mark.parametrize("module", [tailwind_v3, tailwind_v4]) +def test_add_tailwind_to_css_file_inserts_import_without_radix(module): + """Tailwind should still be added when the root stylesheet has no Radix import.""" + css = ( + "@layer __reflex_base;\n" + "@import url('./__reflex_style_reset.css');\n" + "@import url('./style.css');" + ) + + updated_css = module.add_tailwind_to_css_file( + css, + include_radix_themes=False, + ) + + assert updated_css.splitlines() == [ + "@layer __reflex_base;", + "@import url('./__reflex_style_reset.css');", + "@import url('./tailwind.css');", + "@import url('./style.css');", + ] + + +def test_v3_compile_root_style_keeps_expected_output_path(): + """Tailwind v3 should continue writing to the shared tailwind.css path.""" + output_path, _ = tailwind_v3.compile_root_style(include_radix_themes=False) + + assert output_path == str(Path("styles") / "tailwind.css") diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 138f9aad047..8a840be4bbb 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2096,6 +2096,128 @@ def test_app_wrap_compile_theme( assert expected.split(",") == function_app_definition.split(",") +def test_compile_without_radix_components_skips_radix_plugin( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """Pure HTML apps should not include Radix Themes assets or wrappers.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + mock_deprecate = mocker.patch("reflex_base.utils.console.deprecate") + + app.add_page(lambda: rx.el.div("Index"), route="/") + app.add_page(lambda: rx.el.div("404"), route=constants.Page404.SLUG) + app._compile() + + root_stylesheet = ( + web_dir + / constants.Dirs.STYLES + / f"{constants.PageNames.STYLESHEET_ROOT}{constants.Ext.CSS}" + ).read_text() + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "@radix-ui/themes/styles.css" not in root_stylesheet + assert "RadixThemesTheme" not in app_root + mock_deprecate.assert_not_called() + + +def test_compile_with_radix_component_auto_enables_radix_plugin( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """Using a Radix Themes component should enable the plugin with a warning.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + mock_deprecate = mocker.patch("reflex_base.utils.console.deprecate") + + app.add_page(lambda: rx.box("Index"), route="/") + app.add_page(lambda: rx.el.div("404"), route=constants.Page404.SLUG) + app._compile() + + root_stylesheet = ( + web_dir + / constants.Dirs.STYLES + / f"{constants.PageNames.STYLESHEET_ROOT}{constants.Ext.CSS}" + ).read_text() + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "@radix-ui/themes/styles.css" in root_stylesheet + assert 'RadixThemesTheme,{accentColor:"blue"' in app_root + mock_deprecate.assert_called_once() + assert ( + mock_deprecate.call_args.kwargs["feature_name"] + == "Implicit Radix Themes enablement" + ) + + +def test_compile_with_legacy_app_theme_warns_and_enables_radix_plugin( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """``App(theme=...)`` should continue to work with a deprecation warning.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + mock_deprecate = mocker.patch("reflex_base.utils.console.deprecate") + + app.theme = rx.theme(accent_color="plum") + app.add_page(lambda: rx.el.div("Index"), route="/") + app.add_page(lambda: rx.el.div("404"), route=constants.Page404.SLUG) + app._compile() + + root_stylesheet = ( + web_dir + / constants.Dirs.STYLES + / f"{constants.PageNames.STYLESHEET_ROOT}{constants.Ext.CSS}" + ).read_text() + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "@radix-ui/themes/styles.css" in root_stylesheet + assert 'RadixThemesTheme,{accentColor:"plum"' in app_root + mock_deprecate.assert_called_once() + assert mock_deprecate.call_args.kwargs["feature_name"] == "App(theme=...)" + + +def test_explicit_radix_plugin_wins_over_legacy_app_theme( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """Explicit RadixThemesPlugin config should win over deprecated App.theme.""" + conf = rx.Config( + app_name="testing", + plugins=[rx.plugins.RadixThemesPlugin(theme=rx.theme(accent_color="green"))], + ) + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + mock_deprecate = mocker.patch("reflex_base.utils.console.deprecate") + + app.theme = rx.theme(accent_color="plum") + app.add_page(lambda: rx.el.div("Index"), route="/") + app.add_page(lambda: rx.el.div("404"), route=constants.Page404.SLUG) + app._compile() + + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert 'RadixThemesTheme,{accentColor:"green"' in app_root + assert 'RadixThemesTheme,{accentColor:"plum"' not in app_root + mock_deprecate.assert_called_once() + assert mock_deprecate.call_args.kwargs["feature_name"] == "App(theme=...)" + + def test_compile_writes_app_wrap_memo_components( compilable_app: tuple[App, Path], mocker, From 482208c0f1b43b284aa3771bd911702a7381ebea Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 16 Apr 2026 21:34:29 +0500 Subject: [PATCH 33/59] removed the final library reset to fix the hydration error --- reflex/compiler/compiler.py | 315 ++++++++++++++++++------------------ 1 file changed, 154 insertions(+), 161 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 253f0d61e87..ee9cd0bf835 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -943,196 +943,189 @@ def compile_app( base_total = (len(app._unevaluated_pages) * 2) + fixed_steps + len(config.plugins) progress.start() task = progress.add_task("Compiling:", total=base_total) - try: - compile_ctx = CompileContext( - app=app, - pages=list(app._unevaluated_pages.values()), - hooks=CompilerHooks( - plugins=default_page_plugins(style=app.style, plugins=compiler_plugins) - ), + compile_ctx = CompileContext( + app=app, + pages=list(app._unevaluated_pages.values()), + hooks=CompilerHooks( + plugins=default_page_plugins(style=app.style, plugins=compiler_plugins) + ), + ) + + with console.timing("Compile pages"), compile_ctx: + compile_ctx.compile( + evaluate_progress=lambda: progress.advance(task), + render_progress=lambda: progress.advance(task), ) - with console.timing("Compile pages"), compile_ctx: - compile_ctx.compile( - 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 - 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 - - if is_prod_mode() and config.show_built_with_reflex: - app._setup_sticky_badge() + if is_prod_mode() and config.show_built_with_reflex: + app._setup_sticky_badge() - progress.advance(task) + progress.advance(task) - 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 - ] + 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 + ] - # Reinitialize vite config in case runtime options have changed. - compile_results.append(( - constants.ReactRouter.VITE_CONFIG_FILE, - frontend_skeleton._compile_vite_config(config), - )) + # Reinitialize vite config in case runtime options have changed. + compile_results.append(( + constants.ReactRouter.VITE_CONFIG_FILE, + frontend_skeleton._compile_vite_config(config), + )) - all_imports = compile_ctx.all_imports + all_imports = compile_ctx.all_imports - 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) - progress.advance(task) - - app_wrappers = _resolve_app_wrap_components( - app, compile_ctx.app_wrap_components + 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." ) - app_root = app._app_root(app_wrappers) - all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) - + raise ReflexRuntimeError(msg) + 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()) + + ( + memo_components_output, + memo_components_result, + memo_components_imports, + ) = compile_memo_components( + dict.fromkeys(CUSTOM_COMPONENTS.values()), ( - memo_components_output, - memo_components_result, - memo_components_imports, - ) = compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), - ( - *tuple(EXPERIMENTAL_MEMOS.values()), - *tuple(compile_ctx.auto_memo_components.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) + progress.advance(task) + + 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} ), ) - 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, - 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, ) - ) - 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]]] = [] - 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, - )), - radix_themes_plugin=radix_themes_plugin, - unevaluated_pages=list(app._unevaluated_pages.values()), - ) + 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, + )), + radix_themes_plugin=radix_themes_plugin, + unevaluated_pages=list(app._unevaluated_pages.values()), + ) - if save_tasks: - _set_progress_total(progress, task, base_total + len(save_tasks)) + if save_tasks: + _set_progress_total(progress, task, base_total + len(save_tasks)) - progress.advance(task, advance=len(config.plugins)) + progress.advance(task, advance=len(config.plugins)) - compile_results.append( - compile_root_stylesheet( - app.stylesheets, - app.reset_style, - plugins=compiler_plugins, - ) + compile_results.append( + compile_root_stylesheet( + app.stylesheets, + app.reset_style, + plugins=compiler_plugins, ) - progress.advance(task) + ) + progress.advance(task) - compile_results.append(compile_theme(app.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) + for task_fn, args, kwargs in save_tasks: + result = task_fn(*args, **kwargs) + if result is None: progress.advance(task) - - compile_results.append( - compile_contexts(app._state, radix_themes_plugin.get_theme()) - ) + continue + if isinstance(result, list): + compile_results.extend(result) + else: + compile_results.append(result) progress.advance(task) - compile_results.append(compile_app_root(app_root)) - progress.advance(task) + compile_results.append( + compile_contexts(app._state, radix_themes_plugin.get_theme()) + ) + progress.advance(task) + + compile_results.append(compile_app_root(app_root)) + progress.advance(task) - progress.stop() + progress.stop() - if dry_run: - return + if dry_run: + return - with console.timing("Install Frontend Packages"): - app._get_frontend_packages(all_imports) + with console.timing("Install Frontend Packages"): + app._get_frontend_packages(all_imports) - frontend_skeleton.update_react_router_config( - prerender_routes=prerender_routes, - ) - finally: - reset_bundled_libraries() + frontend_skeleton.update_react_router_config( + prerender_routes=prerender_routes, + ) if is_prod_mode(): purge_web_pages_dir() From de521cc88d6d8f02bd62f9db9c65fa5c5a4a0f9f Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 22 Apr 2026 09:59:41 -0700 Subject: [PATCH 34/59] memoize: generated components contain hook calls (#4) * memoize: generated components contain hook calls * move automemoization to leave_component so that replaced value contains memoized children. * memoize Bare values separately to isolate state vars * collect imports/hooks after memoization so resulting page does NOT contain hooks that were rendered in the memoized component body * memoize: wrap MemoizationLeaf subtrees as snapshot boundaries MemoizationLeaf-style components (e.g. rx.upload.root) build internal machinery with stateful hooks as their own structural children. Wrap them whole in enter_component with empty children so the walker skips descent and their hooks stay in the memo body instead of leaking into page scope. Qualname the tag prefix to avoid cross-class collisions and propagate _get_app_wrap_components through the memo wrapper so providers like UploadFilesProvider still reach the app root. --------- Co-authored-by: Farhan Ali Raza --- .../src/reflex_base/components/component.py | 2 +- .../reflex_base/components/memoize_helpers.py | 72 ++++-- .../src/reflex_base/plugins/compiler.py | 6 + .../src/reflex_components_core/core/upload.py | 6 +- .../core/window_events.py | 3 +- pyi_hashes.json | 2 +- reflex/compiler/plugins/builtin.py | 18 +- reflex/compiler/plugins/memoize.py | 219 ++++++++++-------- reflex/experimental/memo.py | 48 +++- tests/units/compiler/test_memoize_plugin.py | 111 ++++++++- tests/units/compiler/test_plugins.py | 34 ++- 11 files changed, 362 insertions(+), 159 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index b69d99497f2..2913e9aa856 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1862,13 +1862,13 @@ def _get_hooks_internal(self) -> dict[str, VarData | None]: return cached result = { + **self._get_events_hooks(), **{ str(hook): VarData(position=Hooks.HookPosition.INTERNAL) for hook in [self._get_ref_hook(), self._get_mount_lifecycle_hook()] if hook is not None }, **self._get_vars_hooks(), - **self._get_events_hooks(), } self._hooks_internal_cache = result return result diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index c7494ba6b8c..f0ebddaebf3 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -68,17 +68,16 @@ def _get_deps_from_event_trigger( def get_memoized_event_triggers( component: Component, -) -> dict[str, tuple[Var, str]]: +) -> dict[str, Var]: """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)``. + A dict mapping event trigger name to memoized_triger. """ - trigger_memo: dict[str, tuple[Var, str]] = {} + trigger_memo: dict[str, Var] = {} for event_trigger, event_args in component._get_vars_from_event_triggers( component.event_triggers ): @@ -91,7 +90,7 @@ def get_memoized_event_triggers( continue event = component.event_triggers[event_trigger] - rendered_chain = str(LiteralVar.create(event)) + rendered_chain = LiteralVar.create(event) chain_hash = md5( str(rendered_chain).encode("utf-8"), usedforsecurity=False @@ -101,28 +100,33 @@ def get_memoized_event_triggers( var_deps = ["addEvents", "ReflexEvent"] var_deps.extend(_get_deps_from_event_trigger(event)) + event_var_data = [] for arg in event_args: var_data = arg._get_all_var_data() if var_data is None: continue + event_var_data.append(var_data) 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")]}), + *event_var_data, + rendered_chain._get_all_var_data(), + VarData( + hooks=[ + f"const {memo_name} = useCallback({rendered_chain!s}, [{', '.join(var_deps)}])" + ], + 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)}])", + trigger_memo[event_trigger] = Var( + _js_expr=memo_name, _var_type=EventChain, _var_data=memo_var_data ) return trigger_memo -def fix_event_triggers_for_memo(component: Component) -> list[str]: +def fix_event_triggers_for_memo(component: Component) -> None: """Memoize ``component.event_triggers`` in place and return hook code. Replaces each (non-lifecycle) event-trigger value on ``component`` with a @@ -131,22 +135,39 @@ def fix_event_triggers_for_memo(component: Component) -> list[str]: 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 + if not memo_event_triggers: + return + # XXX: what is this doing? if we're overwriting the reference to the original dict anyway + component.event_triggers = dict( + component.event_triggers + ) # isolate so original dict is not mutated + for event_trigger, memo_trigger in memo_event_triggers: + component.event_triggers[event_trigger] = memo_trigger + + +def is_snapshot_boundary(component: Component) -> bool: + """Whether ``component`` owns its subtree for memoization purposes. - return memo_trigger_hooks + Snapshot boundaries (``MemoizationLeaf``-style components with + ``_memoization_mode.recursive=False``) encapsulate internal machinery as + their own structural children. The auto-memoize compiler pass must wrap + them whole and not walk or independently memoize that subtree. + + The check is the behavioral flag, not ``isinstance(MemoizationLeaf)``, so + components that opt into non-recursive memoization without subclassing + ``MemoizationLeaf`` are handled identically. + + Args: + component: The component to classify. + + Returns: + ``True`` iff descendants of ``component`` must not be independently + memoized and the memo wrapper must carry the full subtree snapshot. + """ + return not component._memoization_mode.recursive def invalidate_event_trigger_caches(component: Component) -> None: @@ -172,4 +193,5 @@ def invalidate_event_trigger_caches(component: Component) -> None: "fix_event_triggers_for_memo", "get_memoized_event_triggers", "invalidate_event_trigger_caches", + "is_snapshot_boundary", ] diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 548fc9516a1..ce08844643f 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -666,6 +666,12 @@ class PageContext(BaseContext): frontend_imports: ParsedImportDict = dataclasses.field(default_factory=dict) output_path: str | None = None output_code: str | None = None + # Stack of ``id(component)`` for components whose subtree is + # memoize-suppressed. Populated by ``MemoizeStatefulPlugin`` when it + # encounters a ``MemoizationLeaf``-style snapshot boundary and popped on + # the matching ``leave_component``. Non-empty iff we are inside such a + # subtree. + memoize_suppressor_stack: list[int] = dataclasses.field(default_factory=list) def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict: """Return the imports accumulated for this page. 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 ab020eb9884..9cfe62bdd86 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 @@ -363,10 +363,7 @@ def create(cls, *children, **props) -> Component: on_drop_rejected=upload_props["on_drop_rejected"], ) ) - callback_hooks = [] - for trigger_name, (event_var, callback_str) in event_triggers.items(): - upload_props[trigger_name] = event_var - callback_hooks.append(callback_str) + upload_props.update(event_triggers) upload_props = { format.to_camel_case(key): value for key, value in upload_props.items() @@ -391,7 +388,6 @@ def create(cls, *children, **props) -> Component: use_dropzone_arguments._get_all_var_data(), VarData( hooks={ - **dict.fromkeys(callback_hooks, None), f"{left_side} = {right_side};": None, }, imports={ 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 10a7362188d..6f3c000afc4 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 @@ -98,8 +98,7 @@ def create(cls, **props) -> WindowEventListener: from reflex_base.components.memoize_helpers import fix_event_triggers_for_memo real_component = cast("WindowEventListener", super().create(**props)) - hooks = fix_event_triggers_for_memo(real_component) - real_component.hooks = hooks + fix_event_triggers_for_memo(real_component) return real_component def _exclude_props(self) -> list[str]: diff --git a/pyi_hashes.json b/pyi_hashes.json index cd16530d6a8..78ce7ef3fb7 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "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" + "reflex/experimental/memo.pyi": "543141ae5b78907ac2eca52652e5c016" } diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 78d23befd10..e74670ab61d 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -159,9 +159,10 @@ class DefaultCollectorPlugin(Plugin): _compiler_can_replace_enter_component = False _compiler_can_replace_leave_component = False - def enter_component( + def leave_component( self, comp: BaseComponent, + children: tuple[BaseComponent, ...], /, *, page_context: PageContext, @@ -222,15 +223,15 @@ def compile_page( else [] ) - def _compiler_bind_enter_component( + def _compiler_bind_leave_component( self, page_context: PageContext, compile_context: CompileContext, - ) -> Callable[[BaseComponent, bool], None]: - """Bind a positional fast-path enter hook for artifact collection. + ) -> Callable[[BaseComponent, tuple[BaseComponent, ...], bool], None]: + """Bind a positional fast-path leave hook for artifact collection. Returns: - A compiled enter hook that only takes hot-loop positional state. + A compiled leave hook that only takes hot-loop positional state. """ del compile_context @@ -247,8 +248,9 @@ def _compiler_bind_enter_component( base_get_app_wrap_components = Component._get_app_wrap_components seen_app_wrap_methods: set[object] = set() - def enter_component( + def leave_component( comp: BaseComponent, + children: tuple[BaseComponent, ...], in_prop_tree: bool, ) -> None: if not isinstance(comp, Component): @@ -279,7 +281,7 @@ def enter_component( if ref is not None: refs[ref] = None - return enter_component + return leave_component @staticmethod def _collect_component_hooks( @@ -417,7 +419,7 @@ def default_page_plugins( chain: list[Plugin] = [*plugins, DefaultPagePlugin()] if style is not None: chain.append(ApplyStylePlugin(style=style)) - chain.extend((MemoizeStatefulPlugin(), DefaultCollectorPlugin())) + chain.extend((DefaultCollectorPlugin(), MemoizeStatefulPlugin())) return tuple(chain) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index c2ee5337afa..5ada70678b4 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -19,9 +19,7 @@ from __future__ import annotations -import contextlib import dataclasses -from functools import cache from typing import Any from reflex_base.components.component import ( @@ -33,6 +31,7 @@ from reflex_base.components.memoize_helpers import ( fix_event_triggers_for_memo, invalidate_event_trigger_caches, + is_snapshot_boundary, ) from reflex_base.constants.compiler import MemoizationDisposition from reflex_base.plugins import ComponentAndChildren, PageContext @@ -42,15 +41,11 @@ 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 + For special wrappers (``Cond``/``Foreach``/``Match``) we peek at the contained Var instead of recursing into the wrapper component itself. Args: @@ -59,13 +54,10 @@ def _child_var(child: Component) -> Var | Component: 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): @@ -81,6 +73,14 @@ def _compute_memo_tag(component: Component) -> str | None: Returns ``None`` for components that render empty (non-visual components are never memoized). + The class qualname is encoded directly in the tag prefix so that distinct + classes which render identically never collide on a tag. Tag collision + would silently share a single cached memo wrapper across classes and drop + the later class's class-level metadata (e.g. ``_get_app_wrap_components``, + which carries providers like ``UploadFilesProvider`` that must reach the + app root). Baking the qualname into the prefix avoids re-concatenating + the rendered JSX into the hash input on every call. + Args: component: The component to name. @@ -92,7 +92,7 @@ def _compute_memo_tag(component: Component) -> str | None: return None code_hash = _hash_str(_deterministic_hash(rendered_code)) return format.format_state_name( - f"{component.tag or 'Comp'}_{code_hash}" + f"{type(component).__qualname__}_{component.tag or 'Comp'}_{code_hash}" ).capitalize() @@ -109,10 +109,14 @@ def _should_memoize(component: Component) -> bool: Returns: True if the component should be wrapped in a memo definition. """ + from reflex_components_core.base.bare import Bare from reflex_components_core.core.foreach import Foreach if component._memoization_mode.disposition == MemoizationDisposition.NEVER: return False + if isinstance(component, Bare) and component.contents._get_all_var_data(): + # A stateful value will be wrapped in a separate component. + return True if component.tag is None: return False if component._memoization_mode.disposition == MemoizationDisposition.ALWAYS: @@ -123,7 +127,7 @@ def _should_memoize(component: Component) -> bool: if prop_var._get_all_var_data(): return True - # Special-case structural children that are Var wrappers (Bare/Cond/ + # Special-case structural children that are Var wrappers (Cond/ # Foreach/Match). Foreach is always memoized because it produces dynamic # child trees that React must reconcile by key. for child in component.children: @@ -139,41 +143,51 @@ def _should_memoize(component: Component) -> bool: return bool(component.event_triggers) -@cache -def _get_passthrough_memo_component(tag: str) -> tuple[Any, Any]: +_KNOWN_MEMO_TAGS: dict[str, tuple[Any, Any]] = {} + + +def _get_passthrough_memo_component(tag: str, component: Component) -> tuple[Any, Any]: """Return the generated experimental memo wrapper callable and definition. Args: tag: The wrapper's exported component name. + component: The component to wrap. Returns: The memo wrapper callable and its definition. """ - return create_passthrough_component_memo(tag) - - -# --------------------------------------------------------------------------- # -# The plugin # -# --------------------------------------------------------------------------- # + if tag in _KNOWN_MEMO_TAGS: + return _KNOWN_MEMO_TAGS[tag] + memo_wrapper, memo_definition = create_passthrough_component_memo(tag, component) + _KNOWN_MEMO_TAGS[tag] = (memo_wrapper, memo_definition) + return memo_wrapper, memo_definition @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. + """Auto-memoize stateful components with experimental-memo wrappers. + + Registered in ``default_page_plugins`` before ``DefaultCollectorPlugin``. + Two memoization modes, driven by whether the component is a snapshot + boundary (see ``is_snapshot_boundary``): + + - Snapshot boundaries (``MemoizationLeaf``-style): wrapped in + ``enter_component`` and returned with empty structural children. The + walker skips descent, so hooks attached to the leaf's internal children + are captured in the memo body only — never hoisted into the page scope. + - Non-leaf memoizable components: wrapped in ``leave_component`` after + descendants have already compiled, so any inner memo wrappers flow into + this wrapper's children. + + Descendants of a snapshot boundary are never independently memoized; the + boundary owns the wrapping decision for its whole subtree. This is tracked + via ``PageContext.memoize_suppressor_stack`` — a stack of component ids + that pushed suppression, popped in ``leave_component`` when the matching + component leaves. """ _compiler_can_replace_enter_component = True - _compiler_can_replace_leave_component = False + _compiler_can_replace_leave_component = True def enter_component( self, @@ -184,7 +198,20 @@ def enter_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Wrap eligible stateful components in an experimental memo component. + """Memoize snapshot-boundary subtrees before descent. + + Snapshot boundaries (``MemoizationLeaf``-style, see + ``is_snapshot_boundary``) stash state-referencing hooks inside + internally-built structural children. If we waited until + ``leave_component`` to swap the boundary for its memo wrapper, the + walker would have already descended and the collector plugin would + have pulled those hooks into page scope. Returning the wrapper with + empty structural children here causes the walker to skip the descent + entirely — the boundary's full snapshot lives only in the memo + component definition compiled separately. + + Non-boundary components are handled in ``leave_component`` so their + already-compiled children flow into the wrapper. Args: comp: The component being visited. @@ -193,66 +220,26 @@ def enter_component( in_prop_tree: Whether the component is in a prop subtree. Returns: - A ``(wrapper, (comp,))`` tuple replacement when ``comp`` is - memoizable, else ``None``. + A ``(wrapper, ())`` replacement for memoized boundaries, otherwise + ``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] + if page_context.memoize_suppressor_stack: 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: + if not is_snapshot_boundary(comp): 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] + # Boundary not worth wrapping — still suppress descendants so + # they don't memoize independently of the boundary's subtree. + page_context.memoize_suppressor_stack.append(id(comp)) 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,)) + wrapper = self._build_wrapper(comp, compile_context) + return None if wrapper is None else (wrapper, ()) def leave_component( self, @@ -264,26 +251,68 @@ def leave_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Pop the ``MemoizationLeaf`` suppression counter if we pushed one. + """Wrap non-boundary memoizables and pop any suppression this component pushed. Args: comp: The component being visited. - children: Its compiled children (unused). + children: Its compiled children (unused; the wrapper reads from + ``comp.children`` which the walker has already updated). 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). + compile_context: The active compile context. + in_prop_tree: Whether the component is in a prop subtree. Returns: - Always ``None``. + The memo wrapper for non-boundary memoizables, else ``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 + del children + if in_prop_tree: + return None + if not isinstance(comp, Component): + return None + + stack = page_context.memoize_suppressor_stack + if stack and stack[-1] == id(comp): + stack.pop() + + if stack: + return None + + if is_snapshot_boundary(comp): + return None + + if not _should_memoize(comp): + return None + + return self._build_wrapper(comp, compile_context) + + @staticmethod + def _build_wrapper(comp: Component, compile_context: Any) -> BaseComponent | None: + """Return the memo wrapper component for ``comp``, or ``None`` if untagged. + + Mutates ``comp.event_triggers`` in place so the memo body renders the + memoized ``useCallback`` forms, and registers the memo definition on + ``compile_context`` so the memo module compile pass emits it. + + Args: + comp: The component being memoized. + compile_context: The active compile context. + + Returns: + The wrapper instance, or ``None`` if the component's render is + empty and has no meaningful tag. + """ + tag = _compute_memo_tag(comp) + if tag is None: + return None + + fix_event_triggers_for_memo(comp) + invalidate_event_trigger_caches(comp) + + compile_context.memoize_wrappers[tag] = None + wrapper_factory, definition = _get_passthrough_memo_component(tag, comp) + compile_context.auto_memo_components[tag] = definition + + return wrapper_factory() __all__ = ["MemoizeStatefulPlugin"] diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index b6904e6955c..64ea7f437ed 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -5,12 +5,14 @@ import dataclasses import inspect from collections.abc import Callable +from copy import copy from functools import cache, update_wrapper from typing import Any, get_args, get_origin, get_type_hints from reflex_base import constants from reflex_base.components.component import Component from reflex_base.components.dynamic import bundled_libraries +from reflex_base.components.memoize_helpers import is_snapshot_boundary from reflex_base.constants.compiler import SpecialAttributes from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER from reflex_base.utils import format @@ -132,22 +134,40 @@ def _post_init(self, **kwargs): @cache def _get_experimental_memo_component_class( export_name: str, + wrapped_component_type: type[Component] = Component, ) -> type[ExperimentalMemoComponent]: """Get the component subclass for an experimental memo export. + Class-level metadata that the compiler reads via ``type(comp)._get_*()`` + (notably ``_get_app_wrap_components``, which carries providers like + ``UploadFilesProvider`` that must reach the app root) is inherited from + ``wrapped_component_type`` so the wrapper is a transparent substitute for + the original in the compile tree. + Args: export_name: The exported React component name. + wrapped_component_type: The class of the component being memoized. + Defaults to ``Component`` for memos that don't wrap a user + component (e.g. function memos, raw passthroughs). Returns: A cached component subclass with the tag set at class definition time. """ + attrs: dict[str, Any] = { + "__module__": __name__, + "tag": export_name, + } + if ( + wrapped_component_type._get_app_wrap_components + is not Component._get_app_wrap_components + ): + attrs["_get_app_wrap_components"] = staticmethod( + wrapped_component_type._get_app_wrap_components + ) return type( f"ExperimentalMemoComponent_{export_name}", (ExperimentalMemoComponent,), - { - "__module__": __name__, - "tag": export_name, - }, + attrs, ) @@ -919,7 +939,9 @@ def __call__(self, *children: Any, **props: Any) -> ExperimentalMemoComponent: raise TypeError(msg) # Build the component props passed into the memo wrapper. - return _get_experimental_memo_component_class(definition.export_name)._create( + return _get_experimental_memo_component_class( + definition.export_name, type(definition.component) + )._create( children=list(children), memo_definition=definition, **explicit_values, @@ -963,9 +985,9 @@ def _create_component_wrapper( return _ExperimentalMemoComponentWrapper(definition) -@cache def create_passthrough_component_memo( export_name: str, + component: Component, ) -> tuple[ Callable[..., ExperimentalMemoComponent], ExperimentalMemoComponentDefinition, @@ -978,13 +1000,25 @@ def create_passthrough_component_memo( Args: export_name: The exported memo component name. + component: The component to wrap. Returns: The callable memo wrapper and its component definition. """ + # Snapshot-boundary components (see ``is_snapshot_boundary``) own their + # subtree — the ``.children`` slot is internal machinery from the + # subclass's ``.create`` (e.g. the dropzone Div built inside + # ``Upload.create``), not a user content hole. The memoize plugin wraps + # the boundary with no structural children on the page side, so the memo + # body renders the full snapshot rather than a ``{children}``-holed + # template. + snapshot_only = is_snapshot_boundary(component) def passthrough(children: Var[Component]) -> Component: - return Bare.create(children) + new_component = copy(component) + if not snapshot_only: + new_component.children = [Bare.create(children)] + return new_component passthrough.__name__ = format.to_snake_case(export_name) passthrough.__qualname__ = passthrough.__name__ diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 50d0a5d50e1..6a8bff6a380 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -9,6 +9,7 @@ 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.bare import Bare from reflex_components_core.base.fragment import Fragment from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins @@ -69,10 +70,10 @@ def test_should_memoize_catches_direct_state_var_in_prop() -> None: assert _should_memoize(comp) -def test_should_memoize_catches_state_var_in_child_bare() -> None: +def test_should_not_memoize_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) + assert not _should_memoize(comp) def test_should_not_memoize_plain_component() -> None: @@ -81,6 +82,12 @@ def test_should_not_memoize_plain_component() -> None: assert not _should_memoize(comp) +def test_should_memoize_state_var_in_child_cond() -> None: + """A Bare containing state VarData should memoize.""" + comp = Bare.create(STATE_VAR) + assert _should_memoize(comp) + + def test_should_not_memoize_when_disposition_never() -> None: """``MemoizationDisposition.NEVER`` overrides heuristic eligibility.""" comp = Plain.create(STATE_VAR) @@ -143,14 +150,16 @@ def test_memoization_leaf_suppresses_descendant_wrapping() -> None: 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_factory, _definition = create_passthrough_component_memo( + "MyTag", Fragment.create() + ) 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.""" +def test_event_trigger_memoization_not_emit_usecallback_in_page_hooks() -> None: + """Components with event triggers do not get useCallback wrappers at the page level.""" from reflex_base.event import EventChain # Construct an event chain referencing state so _get_memoized_event_triggers @@ -166,15 +175,17 @@ def test_event_trigger_memoization_emits_usecallback_in_page_hooks() -> None: # Check that a useCallback hook line was added to the page hooks dict. hook_lines = list(page_ctx.hooks.keys()) - assert any( + assert not 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}" + ), f"Expected no 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_factory, definition = create_passthrough_component_memo( + "MyWrapper_abc", Fragment.create() + ) wrapper = wrapper_factory(Plain.create()) assert isinstance(wrapper, ExperimentalMemoComponent) assert wrapper.tag == "MyWrapper_abc" @@ -203,14 +214,94 @@ def test_shared_subtree_across_pages_uses_same_tag() -> None: assert f"jsx({tag}," in output +def test_memoization_leaf_internal_hooks_do_not_leak_into_page() -> None: + """Hooks from a ``MemoizationLeaf``'s internal children stay in its memo body. + + ``MemoizationLeaf``-derived components (e.g. ``rx.upload.root``) build + internal machinery as their own structural children, attaching stateful + hooks via ``special_props``/``VarData``. Those hooks belong to the memo + component's function body — not to the page — because the whole point of + the leaf is to isolate its subtree from page-level re-renders. + + The test asserts both directions: the hook lines do not appear in the + page's collected hooks, *and* they do appear in the compiled memo module + (otherwise a regression that drops them entirely would pass the negative + check). + """ + from reflex_base.components.component import MemoizationLeaf + from reflex_base.event import EventChain + from reflex_base.vars.base import Var + + from reflex.compiler.compiler import compile_memo_components + + class StatefulLeaf(MemoizationLeaf): + tag = "StatefulLeaf" + library = "stateful-leaf-lib" + + @classmethod + def create(cls, *children, **props): + # Simulate what rx.upload.root does: build an internal child whose + # special_props carry stateful hook lines via VarData. + internal_hook_var = Var( + _js_expr="__internal_leaf_probe()", + _var_type=None, + _var_data=VarData( + hooks={ + "const __internal_leaf_probe = useLeafProbe();": None, + "const on_drop_xyz = useCallback(() => {}, []);": None, + }, + state="LeafState", + ), + ) + internal_child = Plain.create(*children) + internal_child.special_props = [internal_hook_var] + return super().create(internal_child, **props) + + stateful_event = Var(_js_expr="evt")._replace( + _var_type=EventChain, + merge_var_data=VarData(state="LeafState"), + ) + leaf = StatefulLeaf.create() + leaf.event_triggers["on_something"] = stateful_event + + ctx, page_ctx = _compile_single_page(lambda: leaf) + + page_hook_lines = list(page_ctx.hooks) + leaking_hooks = [ + hook + for hook in page_hook_lines + if "useLeafProbe" in hook or "on_drop_xyz" in hook + ] + assert not leaking_hooks, ( + f"MemoizationLeaf internal hooks leaked into page: {leaking_hooks!r}" + ) + + # The hooks must survive somewhere — in the compiled memo module for the + # generated leaf wrapper. Compile the auto-memo definitions collected + # during the page compile and check that the hook lines are present. + assert ctx.auto_memo_components, ( + "expected an auto-memo wrapper to be generated for the leaf" + ) + _output_path, memo_code, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + assert "useLeafProbe" in memo_code, ( + "leaf's internal probe hook was dropped from the memo module" + ) + assert "on_drop_xyz" in memo_code, ( + "leaf's internal useCallback hook was dropped from the memo module" + ) + + 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. + # And it is registered after 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 + assert memoize_index > collector_index diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 86587d39be2..cc130921333 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -761,8 +761,8 @@ def test_default_page_plugins_are_minimal_and_ordered() -> None: assert len(plugins) == 4 assert isinstance(plugins[0], DefaultPagePlugin) assert isinstance(plugins[1], ApplyStylePlugin) - assert isinstance(plugins[2], MemoizeStatefulPlugin) - assert isinstance(plugins[3], DefaultCollectorPlugin) + assert isinstance(plugins[2], DefaultCollectorPlugin) + assert isinstance(plugins[3], MemoizeStatefulPlugin) def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: @@ -791,7 +791,13 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: 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)] + # `collapse_imports` uses `list(set(...))`, so the per-library ImportVar + # lists don't have a stable order across processes. Compare as sets. + [actual_imports] = page_ctx.imports + expected_imports = page_ctx.root_component._get_all_imports(collapse=True) + assert actual_imports.keys() == expected_imports.keys() + for lib, actual_vars in actual_imports.items(): + assert set(actual_vars) == set(expected_imports[lib]) 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 ( @@ -818,8 +824,26 @@ def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: page_style(), None, ) - expected_output = compiler.compile_page(page.route, legacy_component)[1] - assert page_ctx.output_code == expected_output + legacy_output = compiler.compile_page(page.route, legacy_component)[1] + + # The two compile paths produce the same content but the plugin pipeline + # inserts imports and hoistable const declarations in post-order (leaf + # first) while legacy inserts them in pre-order. Neither order matters to + # the JS engine — imports are hoisted, and the consts don't reference one + # another. Compare the preamble as a set of lines, and the component body + # (where hook order and JSX are meaningful) byte-for-byte. + preamble_marker = "export default function Component" + + def preamble_lines(output: str) -> set[str]: + preamble, _, _ = output.partition(preamble_marker) + return set(preamble.splitlines()) + + def component_body(output: str) -> str: + _, sep, body = output.partition(preamble_marker) + return sep + body + + assert preamble_lines(page_ctx.output_code) == preamble_lines(legacy_output) + assert component_body(page_ctx.output_code) == component_body(legacy_output) def test_default_page_plugin_handles_var_backed_title_like_legacy_compiler() -> None: From ec21152607e6966fd8037441f702d0e43b05b871 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 00:10:29 +0500 Subject: [PATCH 35/59] removed unused kwargs del --- .../reflex-base/src/reflex_base/plugins/base.py | 2 -- .../src/reflex_components_radix/plugin.py | 6 ------ reflex/compiler/plugins/builtin.py | 13 +------------ reflex/compiler/plugins/memoize.py | 1 - reflex/experimental/memo.py | 1 - 5 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index cd3c6a580f2..de4ad381e20 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -135,7 +135,6 @@ def eval_page( Returns: A page context when the plugin can evaluate the page, otherwise ``None``. """ - del page_fn, kwargs return None def compile_page( @@ -145,7 +144,6 @@ def compile_page( **kwargs: Any, ) -> None: """Finalize a page context after its component tree has been traversed.""" - del page_ctx, kwargs return def enter_component( diff --git a/packages/reflex-components-radix/src/reflex_components_radix/plugin.py b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py index f7367a9b486..150183a17ad 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/plugin.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py @@ -47,12 +47,10 @@ def create_implicit(cls) -> RadixThemesPlugin: def get_stylesheet_paths(self, **context: Any) -> tuple[str, ...]: """Return the Radix Themes stylesheet when enabled.""" - del context return (RADIX_THEMES_STYLESHEET,) if self.enabled else () def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]: """Return the Radix Themes package when enabled.""" - del context return (RADIX_THEMES_PACKAGE,) if self.enabled else () def enter_component( @@ -65,8 +63,6 @@ def enter_component( in_prop_tree: bool = False, ) -> None: """Auto-enable the plugin when a Radix Themes component is compiled.""" - del page_context, compile_context, in_prop_tree - if self.enabled or not isinstance(comp, RadixThemesComponent): return @@ -92,8 +88,6 @@ def compile_page( **kwargs: Any, ) -> None: """Inject the app-level theme wrapper when Radix Themes is active.""" - del kwargs - if self.enabled and self.theme is not None: page_ctx.app_wrap_components[20, "Theme"] = self.theme diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index e74670ab61d..d275d6c3045 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -36,8 +36,6 @@ def eval_page( """ from reflex.compiler import compiler - del kwargs - try: component = compiler.into_component(page_fn) component = Fragment.create(component) @@ -110,8 +108,6 @@ def enter_component( in_prop_tree: bool = False, ) -> None: """Apply the non-recursive portion of ``_add_style_recursive``.""" - 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) @@ -125,8 +121,6 @@ def _compiler_bind_enter_component( Returns: A compiled enter hook that only takes hot-loop positional state. """ - del page_context, compile_context - style = self.style if style is None: @@ -134,7 +128,7 @@ def enter_component( comp: BaseComponent, in_prop_tree: bool, ) -> None: - del comp, in_prop_tree + return return enter_component @@ -170,8 +164,6 @@ def leave_component( in_prop_tree: bool = False, ) -> None: """Collect imports and page artifacts for the active component node.""" - del compile_context - if not isinstance(comp, Component): return @@ -206,7 +198,6 @@ def compile_page( **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) @@ -233,8 +224,6 @@ def _compiler_bind_leave_component( Returns: A compiled leave 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 diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 5ada70678b4..5e4149be6fb 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -264,7 +264,6 @@ def leave_component( Returns: The memo wrapper for non-boundary memoizables, else ``None``. """ - del children if in_prop_tree: return None if not isinstance(comp, Component): diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 64ea7f437ed..328d612eedf 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -89,7 +89,6 @@ def _validate_component_children(self, children: list[Component]) -> None: Args: children: The children of the component (ignored). """ - del children def _post_init(self, **kwargs): """Initialize the experimental memo component. From 43b9ccb57bec6f4879ec90736b4404cc8cb0844c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 12:58:46 +0500 Subject: [PATCH 36/59] fix: clone components on write during compile to avoid cross-page mutation Introduces PageContext.own() which returns a page-local shallow copy of a component on first encounter and caches it for subsequent plugin passes. ApplyStylePlugin, the memoize plugin, and the walker's children rebinding now go through own() instead of mutating the user-owned instance. Fixes a ReferenceError when a module-scope layout with a memoizable subtree was shared across multiple pages: page A's compile would rewrite children on the shared parent and leave page B without the memo tag in its tree. --- .../src/reflex_base/components/component.py | 27 +++++- .../reflex_base/components/memoize_helpers.py | 58 ++++++------- .../src/reflex_base/plugins/compiler.py | 53 +++++++++++- .../core/window_events.py | 9 +- pyi_hashes.json | 2 +- reflex/compiler/plugins/builtin.py | 49 +++++++---- reflex/compiler/plugins/memoize.py | 16 ++-- reflex/experimental/memo.py | 7 +- tests/units/compiler/test_memoize_plugin.py | 83 +++++++++++++++++++ tests/units/compiler/test_plugins.py | 17 ++-- 10 files changed, 251 insertions(+), 70 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 2913e9aa856..5604bcbe057 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -318,6 +318,31 @@ def set(self, **kwargs): setattr(self, key, value) return self + def __copy__(self) -> BaseComponent: + """Return a shallow copy suitable for compile-time mutation. + + Bypasses ``copy.copy``'s generic ``__reduce_ex__`` dispatch. Nested + mutable containers (``children``, ``style``, ``event_triggers``) are + shared with the original until the caller explicitly rebinds them. + Render-path caches populated on the original are dropped so the clone + recomputes against its (potentially rebound) fields. + + Returns: + A new instance of the same class with ``__dict__`` shallow-copied. + """ + new = self.__class__.__new__(self.__class__) + new_dict = vars(new) + new_dict.update(vars(self)) + for attr in ( + "_cached_render_result", + "_vars_cache", + "_imports_cache", + "_hooks_internal_cache", + "_get_component_prop_property", + ): + new_dict.pop(attr, None) + return new + def __eq__(self, value: Any) -> bool: """Check if the component is equal to another value. @@ -1459,8 +1484,6 @@ def _get_vars( Yields: Each var referenced by the component (props, styles, event handlers). """ - # 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: diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index f0ebddaebf3..14dd4bc14c1 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -9,8 +9,8 @@ from __future__ import annotations -import contextlib from hashlib import md5 +from typing import TYPE_CHECKING from reflex_base.components.component import Component from reflex_base.constants import EventTriggers @@ -19,6 +19,9 @@ from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var +if TYPE_CHECKING: + from reflex_base.plugins.compiler import PageContext + def _get_hook_deps(hook: str) -> list[str]: """Extract Var deps from a hook declaration line. @@ -126,26 +129,33 @@ def get_memoized_event_triggers( return trigger_memo -def fix_event_triggers_for_memo(component: Component) -> None: - """Memoize ``component.event_triggers`` in place and return hook code. +def fix_event_triggers_for_memo( + component: Component, page_context: PageContext +) -> Component: + """Return a component whose event triggers reference memoized ``useCallback``s. - 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. + Replaces each (non-lifecycle) event-trigger value with a ``Var`` naming a + memoized ``useCallback`` wrapper. The original is never mutated — a + page-local clone is taken via ``page_context.own`` on first write. Args: component: The component whose event triggers to memoize. + page_context: The active page context, used to obtain a page-local + clone before rewriting ``event_triggers``. + + Returns: + Either ``component`` (when nothing needed rewriting) or a page-local + clone with the rewritten ``event_triggers``. """ memo_event_triggers = tuple(get_memoized_event_triggers(component).items()) - if not memo_event_triggers: - return - # XXX: what is this doing? if we're overwriting the reference to the original dict anyway - component.event_triggers = dict( - component.event_triggers - ) # isolate so original dict is not mutated - for event_trigger, memo_trigger in memo_event_triggers: - component.event_triggers[event_trigger] = memo_trigger + return component + owned = page_context.own(component) + owned.event_triggers = { + **component.event_triggers, + **dict(memo_event_triggers), + } + return owned def is_snapshot_boundary(component: Component) -> bool: @@ -170,28 +180,8 @@ def is_snapshot_boundary(component: Component) -> bool: return not component._memoization_mode.recursive -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", "is_snapshot_boundary", ] diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index ce08844643f..575a576b111 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -2,12 +2,13 @@ from __future__ import annotations +import copy 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 import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, TypeVar, cast from typing_extensions import Self @@ -31,6 +32,9 @@ ) +_BaseComponentT = TypeVar("_BaseComponentT", bound=BaseComponent) + + class PageDefinition(Protocol): """Protocol for page-like objects compiled by :class:`CompileContext`.""" @@ -307,6 +311,7 @@ def compile_component( return self._compile_component_single_enter_fast_path( comp, enter_hook=enter_hooks[0], + page_context=page_context, in_prop_tree=in_prop_tree, ) @@ -314,6 +319,7 @@ def compile_component( comp, enter_hooks=enter_hooks, leave_hooks=leave_hooks, + page_context=page_context, in_prop_tree=in_prop_tree, ) @@ -324,6 +330,7 @@ def compile_component( hook_binder(page_context, compile_context) for hook_binder in self._leave_component_hook_binders ), + page_context=page_context, in_prop_tree=in_prop_tree, ) @@ -334,6 +341,7 @@ def _compile_component_without_replacements( *, enter_hooks: tuple[CompiledEnterHook, ...], leave_hooks: tuple[CompiledLeaveHook, ...], + page_context: PageContext, in_prop_tree: bool = False, ) -> BaseComponent: """Walk a component tree when hook plans only observe state. @@ -365,6 +373,7 @@ def visit( updated_children = list(children[:index]) updated_children.append(compiled_child) if updated_children is not None: + current_comp = page_context.own(current_comp) current_comp.children = updated_children if isinstance(current_comp, Component): @@ -396,6 +405,7 @@ def _compile_component_single_enter_fast_path( /, *, enter_hook: CompiledEnterHook, + page_context: PageContext, in_prop_tree: bool = False, ) -> BaseComponent: """Walk a component tree for the common one-enter-hook fast path. @@ -426,6 +436,7 @@ def visit( updated_children = list(children[:index]) updated_children.append(compiled_child) if updated_children is not None: + current_comp = page_context.own(current_comp) current_comp.children = updated_children if isinstance(current_comp, Component): @@ -449,6 +460,7 @@ def _compile_component_with_replacements( *, enter_hooks: tuple[CompiledEnterHook, ...], leave_hooks: tuple[CompiledLeaveHook, ...], + page_context: PageContext, in_prop_tree: bool = False, ) -> BaseComponent: """Walk a component tree while honoring hook replacements. @@ -527,7 +539,12 @@ def visit( current_in_prop_tree, ) - compiled_component.children = list(compiled_children) + current = compiled_component.children + if len(compiled_children) != len(current) or any( + a is not b for a, b in zip(compiled_children, current, strict=True) + ): + compiled_component = page_context.own(compiled_component) + compiled_component.children = list(compiled_children) return compiled_component return visit( @@ -672,6 +689,38 @@ class PageContext(BaseContext): # the matching ``leave_component``. Non-empty iff we are inside such a # subtree. memoize_suppressor_stack: list[int] = dataclasses.field(default_factory=list) + # Maps both the user-owned original's ``id()`` and the clone's ``id()`` to + # the page-local clone. Lets the walker and plugins rebind children, style, + # or event_triggers on a page-local copy without mutating a user-owned + # instance that may be referenced from another route. + _owned: dict[int, BaseComponent] = dataclasses.field(default_factory=dict) + # Strong references to originals keyed by ``id()`` above. Without these, + # an original that is only reachable through ``_owned``'s int key can be + # garbage collected, and Python may recycle its ``id()`` for a fresh + # component, causing ``own()`` to hand back the wrong clone. + _owned_refs: list[BaseComponent] = dataclasses.field(default_factory=list) + + def own(self, comp: _BaseComponentT) -> _BaseComponentT: + """Return a page-local copy of ``comp``, cloning on first encounter. + + Repeated calls with the same original return the same clone, so + mutations from several plugins accumulate on one instance. + + Args: + comp: The component the caller is about to mutate. + + Returns: + A component the caller may freely mutate without touching any + user-owned instance. + """ + existing = self._owned.get(id(comp)) + if existing is not None: + return cast("_BaseComponentT", existing) + new = copy.copy(comp) + self._owned[id(comp)] = new + self._owned[id(new)] = new + self._owned_refs.append(comp) + return new def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict: """Return the imports accumulated for this page. 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 6f3c000afc4..ee57bb770c0 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 @@ -95,10 +95,15 @@ def create(cls, **props) -> WindowEventListener: Returns: The created component. """ - from reflex_base.components.memoize_helpers import fix_event_triggers_for_memo + from reflex_base.components.memoize_helpers import get_memoized_event_triggers real_component = cast("WindowEventListener", super().create(**props)) - fix_event_triggers_for_memo(real_component) + memo_event_triggers = get_memoized_event_triggers(real_component) + if memo_event_triggers: + real_component.event_triggers = { + **real_component.event_triggers, + **memo_event_triggers, + } return real_component def _exclude_props(self) -> list[str]: diff --git a/pyi_hashes.json b/pyi_hashes.json index 78ce7ef3fb7..14296c318ca 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "543141ae5b78907ac2eca52652e5c016" + "reflex/experimental/memo.pyi": "3f44859f8bd7453c2dce15353e13dbce" } diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index d275d6c3045..30e607f297b 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -70,33 +70,45 @@ def eval_page( class ApplyStylePlugin(Plugin): """Apply app-level styles in the descending phase of the walk.""" - _compiler_can_replace_enter_component = False + _compiler_can_replace_enter_component = True style: ComponentStyle | None = None @staticmethod - def _apply_style(comp: Component, style: ComponentStyle) -> None: + def _apply_style( + comp: Component, style: ComponentStyle, page_context: PageContext + ) -> Component | None: """Apply app-level styles to a single component. Args: comp: The component to style. style: The app-level component style map. + page_context: The active page context, used to obtain a page-local + clone before rewriting ``style``. + + Returns: + A page-local clone with the merged style, or ``None`` when the + component has no type-level or app-level style to apply. """ 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 not new_style and not component_style: + return None + + style_vars = [new_style._var_data] 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 + + owned = page_context.own(comp) + owned.style = new_style + return owned def enter_component( self, @@ -106,16 +118,22 @@ def enter_component( page_context: PageContext, compile_context: Any, in_prop_tree: bool = False, - ) -> None: - """Apply the non-recursive portion of ``_add_style_recursive``.""" + ) -> BaseComponent | None: + """Apply the non-recursive portion of ``_add_style_recursive``. + + Returns: + A page-local clone carrying the merged style, or ``None`` when no + style change applies to this component. + """ if self.style is not None and isinstance(comp, Component) and not in_prop_tree: - self._apply_style(comp, self.style) + return self._apply_style(comp, self.style, page_context) + return None def _compiler_bind_enter_component( self, page_context: PageContext, compile_context: CompileContext, - ) -> Callable[[BaseComponent, bool], None]: + ) -> Callable[[BaseComponent, bool], BaseComponent | None]: """Bind a positional fast-path enter hook for style application. Returns: @@ -127,8 +145,8 @@ def _compiler_bind_enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - ) -> None: - return + ) -> BaseComponent | None: + return None return enter_component @@ -137,11 +155,10 @@ def enter_component( def enter_component( comp: BaseComponent, in_prop_tree: bool, - ) -> None: + ) -> BaseComponent | None: if not isinstance(comp, Component) or in_prop_tree: - return - - apply_style(comp, style) + return None + return apply_style(comp, style, page_context) return enter_component diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 5e4149be6fb..74b1349a1f6 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -30,7 +30,6 @@ ) from reflex_base.components.memoize_helpers import ( fix_event_triggers_for_memo, - invalidate_event_trigger_caches, is_snapshot_boundary, ) from reflex_base.constants.compiler import MemoizationDisposition @@ -238,7 +237,7 @@ def enter_component( page_context.memoize_suppressor_stack.append(id(comp)) return None - wrapper = self._build_wrapper(comp, compile_context) + wrapper = self._build_wrapper(comp, page_context, compile_context) return None if wrapper is None else (wrapper, ()) def leave_component( @@ -282,18 +281,22 @@ def leave_component( if not _should_memoize(comp): return None - return self._build_wrapper(comp, compile_context) + return self._build_wrapper(comp, page_context, compile_context) @staticmethod - def _build_wrapper(comp: Component, compile_context: Any) -> BaseComponent | None: + def _build_wrapper( + comp: Component, page_context: PageContext, compile_context: Any + ) -> BaseComponent | None: """Return the memo wrapper component for ``comp``, or ``None`` if untagged. - Mutates ``comp.event_triggers`` in place so the memo body renders the + Rewrites ``comp.event_triggers`` on a page-local clone via + :func:`fix_event_triggers_for_memo` so the memo body renders the memoized ``useCallback`` forms, and registers the memo definition on ``compile_context`` so the memo module compile pass emits it. Args: comp: The component being memoized. + page_context: The active page context. compile_context: The active compile context. Returns: @@ -304,8 +307,7 @@ def _build_wrapper(comp: Component, compile_context: Any) -> BaseComponent | Non if tag is None: return None - fix_event_triggers_for_memo(comp) - invalidate_event_trigger_caches(comp) + comp = fix_event_triggers_for_memo(comp, page_context) compile_context.memoize_wrappers[tag] = None wrapper_factory, definition = _get_passthrough_memo_component(tag, comp) diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 328d612eedf..1c08c97e3c3 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -13,7 +13,11 @@ from reflex_base.components.component import Component from reflex_base.components.dynamic import bundled_libraries from reflex_base.components.memoize_helpers import is_snapshot_boundary -from reflex_base.constants.compiler import SpecialAttributes +from reflex_base.constants.compiler import ( + MemoizationDisposition, + MemoizationMode, + SpecialAttributes, +) from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER from reflex_base.utils import format from reflex_base.utils.imports import ImportVar @@ -77,6 +81,7 @@ class ExperimentalMemoComponent(Component): """A rendered instance of an experimental memo component.""" library = f"$/{constants.Dirs.COMPONENTS_PATH}" + _memoization_mode = MemoizationMode(disposition=MemoizationDisposition.NEVER) def _validate_component_children(self, children: list[Component]) -> None: """Skip direct parent/child validation for memo wrapper instances. diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 6a8bff6a380..8f1d40dd464 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -214,6 +214,89 @@ def test_shared_subtree_across_pages_uses_same_tag() -> None: assert f"jsx({tag}," in output +def test_shared_parent_instance_across_pages_preserves_original() -> None: + """A parent instance reused across pages must not have its children rebound. + + Regression: the compile walker replaces memoizable descendants with memo + wrappers and writes the new children list onto their parent. If the parent + is the same Python object on two pages (e.g. a module-scope layout), page + A's compile would mutate page B's starting tree, producing a ``ReferenceError`` + for the memo tag on the second page. + """ + shared_parent = Fragment.create(WithProp.create(label=STATE_VAR)) + original_children = list(shared_parent.children) + original_child = shared_parent.children[0] + + ctx = CompileContext( + pages=[ + FakePage(route="/a", component=lambda: shared_parent), + FakePage(route="/b", component=lambda: shared_parent), + ], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx: + ctx.compile() + + assert shared_parent.children == original_children, ( + f"shared parent's children mutated: {shared_parent.children!r}" + ) + assert shared_parent.children[0] is original_child, ( + "shared parent's child reference replaced by a memo wrapper" + ) + + assert len(ctx.memoize_wrappers) == 1 + tag = next(iter(ctx.memoize_wrappers)) + for route in ("/a", "/b"): + output = ctx.compiled_pages[route].output_code or "" + assert f'import {{{tag}}} from "$/utils/components"' in output, ( + f"route {route} missing memo tag import" + ) + assert f"jsx({tag}," in output, f"route {route} does not render the memo tag" + + +def test_shared_nested_parent_mirroring_common_elements_preserves_original() -> None: + """Deeper nested shape — mirrors ``common_elements`` in test_event_chain. + + ``common_elements`` is an outer ``rx.vstack`` that contains an inner + ``rx.vstack(rx.foreach(...))`` memoizable subtree. The walker must clone + the entire spine from the memoized descendant up to the shared root, not + just the immediate parent. + """ + inner_parent = Fragment.create(WithProp.create(label=STATE_VAR)) + shared_outer = Fragment.create( + WithProp.create(label=LiteralVar.create("static")), + inner_parent, + WithProp.create(label=LiteralVar.create("trailing")), + ) + original_outer_children = list(shared_outer.children) + original_inner = shared_outer.children[1] + original_inner_children = list(inner_parent.children) + original_innermost = inner_parent.children[0] + + ctx = CompileContext( + pages=[ + FakePage(route="/a", component=lambda: shared_outer), + FakePage(route="/b", component=lambda: shared_outer), + FakePage(route="/c", component=lambda: shared_outer), + ], + hooks=CompilerHooks(plugins=default_page_plugins()), + ) + with ctx: + ctx.compile() + + assert shared_outer.children == original_outer_children + assert shared_outer.children[1] is original_inner + assert inner_parent.children == original_inner_children + assert inner_parent.children[0] is original_innermost + + assert len(ctx.memoize_wrappers) == 1 + tag = next(iter(ctx.memoize_wrappers)) + for route in ("/a", "/b", "/c"): + 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_memoization_leaf_internal_hooks_do_not_leak_into_page() -> None: """Hooks from a ``MemoizationLeaf``'s internal children stay in its memo body. diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index cc130921333..432529d609a 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -707,24 +707,31 @@ def test_apply_style_plugin_matches_legacy_style_behavior() -> None: legacy_component._add_style_recursive(page_style()) + original_style_snapshot = normalize_style(component) + original_child_style_snapshot = normalize_style(component.children[0]) + 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( + compiled = 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( + assert normalize_style(compiled) == normalize_style(legacy_component) + assert normalize_style(compiled.children[0]) == normalize_style( legacy_component.children[0] ) - assert component.slot is not None + assert isinstance(compiled, type(legacy_component)) + assert compiled.slot is not None assert legacy_component.slot is not None - assert normalize_style(component.slot) == normalize_style(legacy_component.slot) + assert normalize_style(compiled.slot) == normalize_style(legacy_component.slot) + + assert normalize_style(component) == original_style_snapshot + assert normalize_style(component.children[0]) == original_child_style_snapshot def test_default_collector_matches_legacy_collectors() -> None: From 45ad77b8eda47db812f985df9a42959a16bb3df4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 14:43:57 +0500 Subject: [PATCH 37/59] fix: per memo file and treeshaking --- .../src/reflex_base/compiler/templates.py | 77 +++++++ pyi_hashes.json | 119 ----------- reflex/compiler/compiler.py | 192 ++++++++++++------ reflex/compiler/utils.py | 12 ++ reflex/experimental/memo.py | 5 + tests/units/compiler/test_memoize_plugin.py | 13 +- tests/units/compiler/test_plugins.py | 5 +- tests/units/experimental/test_memo.py | 28 ++- tests/units/test_app.py | 14 +- 9 files changed, 261 insertions(+), 204 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index de4dc8a3776..3c525e99f4e 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -715,6 +715,83 @@ def memo_components_template( {components_code}""" +def memo_single_component_template( + imports: list[_ImportDict], + component: dict[str, Any], + dynamic_imports: Iterable[str], + custom_codes: Iterable[str], +) -> str: + """Template for a single memoized component in its own module. + + Args: + imports: List of import statements for this memo only. + component: The single component definition to render. + dynamic_imports: Dynamic import statements scoped to this memo. + custom_codes: Custom code snippets scoped to this memo. + + Returns: + The rendered standalone memo module code. + """ + imports_str = "\n".join([_RenderUtils.get_import(imp) for imp in imports]) + dynamic_imports_str = "\n".join(dynamic_imports) + custom_code_str = "\n".join(custom_codes) + + component_code = f""" +export const {component["name"]} = memo(({component["signature"]}) => {{ + {_render_hooks(component.get("hooks", {}))} + return( + {_RenderUtils.render(component["render"])} + ) +}}); +""" + + return f""" +{imports_str} + +{dynamic_imports_str} + +{custom_code_str} + +{component_code}""" + + +def memo_single_function_template( + imports: list[_ImportDict], + function: dict[str, Any], +) -> str: + """Template for a single function memo in its own module. + + Args: + imports: List of import statements for this memo only. + function: The single function memo definition. + + Returns: + The rendered standalone function memo module code. + """ + imports_str = "\n".join([_RenderUtils.get_import(imp) for imp in imports]) + return f""" +{imports_str} + +export const {function["name"]} = {function["function"]}; +""" + + +def memo_index_template(reexports: Iterable[tuple[str, str]]) -> str: + """Template for the memo index module that re-exports every memo file. + + Args: + reexports: Iterable of ``(export_name, relative_module_specifier)``. + + Returns: + The rendered index module code. + """ + lines = [ + f'export {{ {export_name} }} from "{specifier}";' + for export_name, specifier in reexports + ] + return "\n".join(lines) + "\n" + + def styles_template(stylesheets: list[str]) -> str: """Template for styles.css. diff --git a/pyi_hashes.json b/pyi_hashes.json index 27e8452cac3..193025eed6c 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": "8e379fa038c7c6c0672639eb5902934d", - "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": "31da62c4d8c1d459089aab32cd232feb", - "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": "3f44859f8bd7453c2dce15353e13dbce" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index ee9cd0bf835..53c681d89d3 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -366,70 +366,152 @@ def _compile_component(component: Component) -> str: def _compile_memo_components( components: Iterable[CustomComponent], experimental_memos: Iterable[ExperimentalMemoDefinition] = (), -) -> tuple[str, dict[str, list[ImportVar]]]: - """Compile the components. +) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: + """Compile each memo/custom-component as its own module plus an index. + + Each memo lands in ``.web//.jsx`` with only the imports + it actually uses. Experimental memo wrappers declare their ``library`` as + that per-memo file path so page-side imports resolve directly to the + individual module. + + The ``$/utils/components`` index only re-exports the legacy + ``@rx.memo`` custom components, which are the ones app-level code + (``root.jsx``) imports by name. Keeping experimental memos out of the + index is what lets root's ``import * as utils_components`` avoid + transitively dragging every page-specific memo into the always-loaded + chunk — the tree-shaking win of per-memo files relies on that. Args: components: The components to compile. experimental_memos: The experimental memos to compile. Returns: - The compiled components. + A list of ``(path, code)`` pairs to write — one per memo plus one + index — and the aggregated imports across all memo modules. """ - imports: dict[str, list[ImportVar]] = {} - component_renders = [] - function_renders = [] + per_memo_files: list[tuple[str, str]] = [] + # Only legacy custom components go through the index: they are the ones + # root.jsx/custom code imports by name from ``$/utils/components``. + # Experimental memos declare their library per-file (see + # ``_get_experimental_memo_component_class``) so pages import them + # directly and the index stays small. + index_entries: list[tuple[str, str]] = [] + aggregate_imports: dict[str, list[ImportVar]] = {} + + base_dir = utils.get_memo_components_dir() - # Compile each component. for component in components: component_render, component_imports = utils.compile_custom_component(component) - component_renders.append(component_render) - imports = utils.merge_imports(imports, component_imports) + name = component_render["name"] + code, file_imports = _compile_single_memo_component( + component_render, component_imports + ) + path = _memo_component_file_path(base_dir, name) + specifier = _memo_component_index_specifier(name) + per_memo_files.append((path, code)) + index_entries.append((name, specifier)) + aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) for memo in experimental_memos: if isinstance(memo, ExperimentalMemoComponentDefinition): memo_render, memo_imports = utils.compile_experimental_component_memo(memo) - component_renders.append(memo_render) - imports = utils.merge_imports(imports, memo_imports) + name = memo_render["name"] + code, file_imports = _compile_single_memo_component( + memo_render, memo_imports + ) + path = _memo_component_file_path(base_dir, name) + per_memo_files.append((path, code)) + aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) elif isinstance(memo, ExperimentalMemoFunctionDefinition): memo_render, memo_imports = utils.compile_experimental_function_memo(memo) - function_renders.append(memo_render) - imports = utils.merge_imports(imports, memo_imports) - - if component_renders: - imports = utils.merge_imports( - { - "react": [ImportVar(tag="memo")], - f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="isTrue")], - }, - imports, - ) - _apply_common_imports(imports) + name = memo_render["name"] + code, file_imports = _compile_single_memo_function( + memo_render, memo_imports + ) + path = _memo_component_file_path(base_dir, name) + per_memo_files.append((path, code)) + aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) - dynamic_imports = { - comp_import: None - for comp_render in component_renders - if "dynamic_imports" in comp_render - for comp_import in comp_render["dynamic_imports"] - } + index_path = utils.get_components_path() + index_code = templates.memo_index_template(index_entries) + return [(index_path, index_code), *per_memo_files], aggregate_imports - custom_codes = { - comp_custom_code: None - for comp_render in component_renders - for comp_custom_code in comp_render.get("custom_code", []) - } - # Compile the components page. - return ( - templates.memo_components_template( - imports=utils.compile_imports(imports), - components=component_renders, - functions=function_renders, - dynamic_imports=sorted(dynamic_imports), - custom_codes=custom_codes, - ), - imports, +def _compile_single_memo_component( + component_render: dict, + component_imports: dict[str, list[ImportVar]], +) -> tuple[str, dict[str, list[ImportVar]]]: + """Render one memoized component as a standalone module. + + Args: + component_render: The component's render dict. + component_imports: The component's imports before common/common-memo + additions. + + Returns: + The file contents and the full import dict used to compile it. + """ + imports = utils.merge_imports( + { + "react": [ImportVar(tag="memo")], + f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="isTrue")], + }, + component_imports, ) + _apply_common_imports(imports) + code = templates.memo_single_component_template( + imports=utils.compile_imports(imports), + component=component_render, + dynamic_imports=sorted(component_render.get("dynamic_imports", []) or []), + custom_codes=component_render.get("custom_code", []) or [], + ) + return code, imports + + +def _compile_single_memo_function( + function_render: dict, + function_imports: dict[str, list[ImportVar]], +) -> tuple[str, dict[str, list[ImportVar]]]: + """Render one function memo as a standalone module. + + Args: + function_render: The function's render dict. + function_imports: The function's imports. + + Returns: + The file contents and the full import dict used to compile it. + """ + imports = utils.merge_imports({}, function_imports) + code = templates.memo_single_function_template( + imports=utils.compile_imports(imports), + function=function_render, + ) + return code, imports + + +def _memo_component_file_path(base_dir: str, name: str) -> str: + """Return the on-disk path for a per-memo module. + + Args: + base_dir: The directory that holds per-memo files. + name: The memo's export name. + + Returns: + The absolute path for the memo's ``.jsx`` file. + """ + return str(Path(base_dir) / f"{name}{constants.Ext.JSX}") + + +def _memo_component_index_specifier(name: str) -> str: + """Return the module specifier the index uses to re-export a memo. + + Args: + name: The memo's export name. + + Returns: + A relative specifier resolvable from the memo index module. + """ + return f"./{constants.PageNames.COMPONENTS}/{name}" def compile_document_root( @@ -568,22 +650,18 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: def compile_memo_components( components: Iterable[CustomComponent], experimental_memos: Iterable[ExperimentalMemoDefinition] = (), -) -> tuple[str, str, dict[str, list[ImportVar]]]: - """Compile the custom components. +) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: + """Compile the custom components into one module per memo plus an index. Args: components: The custom components to compile. experimental_memos: The experimental memos to compile. Returns: - The path and code of the compiled components. + A list of ``(path, code)`` pairs (one per memo module and one index) + alongside the aggregated imports across all memo modules. """ - # Get the path for the output file. - output_path = utils.get_components_path() - - # Compile the components. - code, imports = _compile_memo_components(components, experimental_memos) - return output_path, code, imports + return _compile_memo_components(components, experimental_memos) def purge_web_pages_dir(): @@ -1015,18 +1093,14 @@ 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( + memo_component_files, memo_components_imports = compile_memo_components( dict.fromkeys(CUSTOM_COMPONENTS.values()), ( *tuple(EXPERIMENTAL_MEMOS.values()), *tuple(compile_ctx.auto_memo_components.values()), ), ) - compile_results.append((memo_components_output, memo_components_result)) + compile_results.extend(memo_component_files) all_imports = utils.merge_imports(all_imports, memo_components_imports) progress.advance(task) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 3dad8c3cd1f..6fec11618d7 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -656,6 +656,18 @@ def get_components_path() -> str: ) +def get_memo_components_dir() -> str: + """Get the directory that holds per-memo module files. + + Returns: + The directory used for per-memo ``.jsx`` modules re-exported by the + top-level components index. + """ + return str( + get_web_dir() / constants.Dirs.UTILS / constants.PageNames.COMPONENTS, + ) + + def add_meta( page: Component, title: str, diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 1c08c97e3c3..118d894ed0a 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -160,6 +160,11 @@ def _get_experimental_memo_component_class( attrs: dict[str, Any] = { "__module__": __name__, "tag": export_name, + # Point each memo at its own per-file module so pages import directly + # from ``$/utils/components/`` rather than through the index. + # Per-file import paths give Vite distinct module boundaries per + # memo, enabling actual code-split by page. + "library": f"$/{constants.Dirs.COMPONENTS_PATH}/{export_name}", } if ( wrapped_component_type._get_app_wrap_components diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 8f1d40dd464..50419cf7f64 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -109,7 +109,7 @@ def test_memoize_wrapper_uses_experimental_memo_component_and_call_site() -> Non 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'import {{{wrapper_tag}}} from "$/utils/components/{wrapper_tag}"' in output assert f"jsx({wrapper_tag}," in (page_ctx.output_code or "") assert f"const {wrapper_tag} = memo" not in output @@ -126,7 +126,7 @@ def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None: 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"' + f'import {{{wrapper_tag}}} from "$/utils/components/{wrapper_tag}"' ) == 1 @@ -210,7 +210,7 @@ def test_shared_subtree_across_pages_uses_same_tag() -> None: 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'import {{{tag}}} from "$/utils/components/{tag}"' in output assert f"jsx({tag}," in output @@ -248,7 +248,7 @@ def test_shared_parent_instance_across_pages_preserves_original() -> None: tag = next(iter(ctx.memoize_wrappers)) for route in ("/a", "/b"): output = ctx.compiled_pages[route].output_code or "" - assert f'import {{{tag}}} from "$/utils/components"' in output, ( + assert f'import {{{tag}}} from "$/utils/components/{tag}"' in output, ( f"route {route} missing memo tag import" ) assert f"jsx({tag}," in output, f"route {route} does not render the memo tag" @@ -293,7 +293,7 @@ def test_shared_nested_parent_mirroring_common_elements_preserves_original() -> tag = next(iter(ctx.memoize_wrappers)) for route in ("/a", "/b", "/c"): output = ctx.compiled_pages[route].output_code or "" - assert f'import {{{tag}}} from "$/utils/components"' in output + assert f'import {{{tag}}} from "$/utils/components/{tag}"' in output assert f"jsx({tag}," in output @@ -365,10 +365,11 @@ def create(cls, *children, **props): assert ctx.auto_memo_components, ( "expected an auto-memo wrapper to be generated for the leaf" ) - _output_path, memo_code, _memo_imports = compile_memo_components( + memo_files, _memo_imports = compile_memo_components( components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) + memo_code = "\n".join(code for _, code in memo_files) assert "useLeafProbe" in memo_code, ( "leaf's internal probe hook was dropped from the memo module" ) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 432529d609a..c960f5366b0 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -940,7 +940,10 @@ def test_compile_context_memoize_wrappers_registers_shared_subtree_tag() -> None 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'import {{{wrapper_tag}}} from "$/utils/components/{wrapper_tag}"' + 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. diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index 236b6b115ad..f66d95a55a9 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -101,9 +101,8 @@ def my_card( assert isinstance(definition, ExperimentalMemoComponentDefinition) assert any(str(prop) == "rest" for prop in definition.component.special_props) - _, code, _ = compiler.compile_memo_components( - (), tuple(EXPERIMENTAL_MEMOS.values()) - ) + files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) assert "export const MyCard = memo(({children, title:title" in code assert "...rest" in code assert "jsx(RadixThemesBox,{...rest}" in code @@ -126,9 +125,8 @@ def conditional_slot( "contents": "(showRxMemo ? firstRxMemo : secondRxMemo)" } - _, code, _ = compiler.compile_memo_components( - (), tuple(EXPERIMENTAL_MEMOS.values()) - ) + files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) assert "export const ConditionalSlot = memo(({show:showRxMemo" in code assert "(showRxMemo ? firstRxMemo : secondRxMemo)" in code @@ -151,9 +149,8 @@ def merge_styles( assert '["color"] : "red"' in str(merged) assert '["className"] : "primary"' in str(merged) - _, code, _ = compiler.compile_memo_components( - (), tuple(EXPERIMENTAL_MEMOS.values()) - ) + files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) assert ( "export const merge_styles = (({base, ...overrides}) => ({...base, ...overrides}));" in code @@ -185,9 +182,8 @@ def label_slot( assert '["children"]' in str(rendered) assert '["className"] : "slot"' in str(rendered) - _, code, _ = compiler.compile_memo_components( - (), tuple(EXPERIMENTAL_MEMOS.values()) - ) + files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) assert "export const label_slot = (({children, label, ...rest}) => label);" in code @@ -356,10 +352,11 @@ def format_price(amount: rx.Var[int], currency: rx.Var[str]) -> rx.Var[str]: def my_card(children: rx.Var[rx.Component], *, title: rx.Var[str]) -> rx.Component: return rx.box(rx.heading(title), children) - _, code, _ = compiler.compile_memo_components( + files, _ = compiler.compile_memo_components( dict.fromkeys(CUSTOM_COMPONENTS.values()), tuple(EXPERIMENTAL_MEMOS.values()), ) + code = "\n".join(c for _, c in files) assert "export const OldWrapper = memo(" in code assert "export const format_price =" in code @@ -451,8 +448,7 @@ def add_custom_code(self) -> list[str]: def foo_component(label: rx.Var[str]) -> rx.Component: return FooComponent.create(label, rx.Var("foo")) - _, code, _ = compiler.compile_memo_components( - (), tuple(EXPERIMENTAL_MEMOS.values()) - ) + files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) assert "const foo = 'bar'" in code diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 33edb231c74..b20607b5f2c 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2242,14 +2242,22 @@ def test_compile_writes_app_wrap_memo_components( app.add_page(rx.box("Index"), route="/") app._compile() - components_js = ( + components_index = ( 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 + # Per-memo modules live under .web/utils/components/; the index re-exports + # each one so page-side ``$/utils/components`` resolves the same tags. + assert "DefaultOverlayComponents" in components_index + assert "MemoizedToastProvider" in components_index + assert 'from "./components/DefaultOverlayComponents"' in components_index + assert 'from "./components/MemoizedToastProvider"' in components_index + + memo_dir = web_dir / constants.Dirs.UTILS / constants.PageNames.COMPONENTS + assert (memo_dir / f"DefaultOverlayComponents{constants.Ext.JSX}").exists() + assert (memo_dir / f"MemoizedToastProvider{constants.Ext.JSX}").exists() def test_compile_writes_upload_files_provider_app_wrap( From 6eac92c6fc3396b5bdf60af1919a49152d218f59 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 15:08:20 +0500 Subject: [PATCH 38/59] revert unrelated change --- .../event/processor/event_processor.py | 6 +---- .../reflex_components_core/core/_upload.py | 26 +++---------------- .../event/processor/test_event_processor.py | 24 ----------------- tests/units/test_app.py | 2 +- 4 files changed, 5 insertions(+), 53 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 47d9c196224..7d9296fe4dd 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 @@ -415,7 +415,6 @@ 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. @@ -433,8 +432,6 @@ 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. @@ -467,8 +464,7 @@ async def _emit_delta_impl( emit_delta_impl=_emit_delta_impl, ), ) - if on_task_future is not None: - on_task_future(task_future) + try: async for delta in _stream_queue_until_done( queue=deltas, done_when=task_future.wait_all() 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 9179bb01f84..680fd7c613f 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 @@ -23,7 +23,6 @@ from typing_extensions import Self if TYPE_CHECKING: - from reflex_base.event.processor import EventFuture from reflex_base.utils.types import ASGIApp, Receive, Scope, Send from reflex.app import App @@ -496,26 +495,11 @@ 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.""" + def _mark_disconnected() -> None: nonlocal disconnect_seen disconnect_seen = True - _try_cancel() async def _ndjson_updates(): """Process the upload event, generating ndjson updates. @@ -528,18 +512,14 @@ async def _ndjson_updates(): 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, - event, - on_task_future=_remember_task_future, - ): + async for delta in app.event_processor.enqueue_stream_delta(token, event): yield json_dumps(StateUpdate(delta=delta)) + "\n" return DisconnectAwareStreamingResponse( _ndjson_updates(), media_type="application/x-ndjson", on_finish=_close_form_data, - on_disconnect=_cancel_upload_task, + on_disconnect=_mark_disconnected, ) 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 a8a0055b1db..bcc1108be98 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -562,30 +562,6 @@ 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 b20607b5f2c..a772b3a3eae 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1348,7 +1348,7 @@ async def form(): # noqa: RUF029 stream_closed = asyncio.Event() - async def enqueue_stream_delta(_token, _event, on_task_future=None): + async def enqueue_stream_delta(_token, _event): try: yield {"state": {"ok": True}} await asyncio.Event().wait() From 707c215f5b402e3c5cc965ad8b78a02721451218 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 16:18:14 +0500 Subject: [PATCH 39/59] Preserve nested memo imports while keeping passthrough memo bodies root-only --- pyi_hashes.json | 3 +- reflex/compiler/utils.py | 170 ++++++++++++++++++++--- reflex/experimental/memo.py | 33 ++++- tests/units/components/test_component.py | 16 +-- 4 files changed, 186 insertions(+), 36 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 193025eed6c..e5b39bd2505 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,6 @@ { + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "d74bda907214feebebd9a71ea590cf34", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "3f44859f8bd7453c2dce15353e13dbce" + "reflex/experimental/memo.pyi": "ff331d6a8c5dc718ee481fdc06f5d4cf" } diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 6fec11618d7..c2bdf618650 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -336,11 +336,15 @@ def compile_custom_component( render = component.get_component() # Get the imports. - imports: ParsedImportDict = { - lib: fields - for lib, fields in render._get_all_imports().items() - if lib != component.library - } + imports: ParsedImportDict = {} + for lib, fields in render._get_all_imports().items(): + if lib != component.library: + imports[lib] = fields + continue + + filtered_fields = [field for field in fields if field.tag != component.tag] + if filtered_fields: + imports[lib] = filtered_fields imports.setdefault("@emotion/react", []).append(ImportVar("jsx")) @@ -373,15 +377,47 @@ def _apply_component_style_for_compile(component: Component) -> Component: Returns: The styled component tree. """ + component._add_style_recursive(_app_style()) + return component + + +def _apply_root_style(component: Component) -> None: + """Merge app-level style into ``component.style`` without recursing. + + Used for passthrough memo bodies where descendants render (and are styled) + in the page scope — only the root's style needs merging here. + + Args: + component: The root component to style in place. + """ + if type(component)._add_style != Component._add_style: + msg = "Do not override _add_style directly. Use add_style instead." + raise UserWarning(msg) + style = _app_style() + new_style = component._add_style() + style_vars = [new_style._var_data] + component_style = component._get_component_style(style) + if component_style: + new_style.update(component_style) + style_vars.append(component_style._var_data) + new_style.update(component.style) + style_vars.append(component.style._var_data) + new_style._var_data = VarData.merge(*style_vars) + component.style = new_style + + +def _app_style() -> ComponentStyle | Style: + """Return the active app-level component style map, or an empty one. + + Returns: + The app-level style map. + """ try: from reflex.utils.prerequisites import get_and_validate_app - style = get_and_validate_app().app.style + return get_and_validate_app().app.style except Exception: - style = {} - - component._add_style_recursive(style) - return component + return {} def compile_experimental_component_memo( @@ -395,12 +431,49 @@ def compile_experimental_component_memo( Returns: A tuple of the compiled component definition and its imports. """ - render = _apply_component_style_for_compile(copy.deepcopy(definition.component)) - + hole_child = definition.passthrough_hole_child + if hole_child is not None: + # Passthrough memo: shallow-copy the root only — ``render.children`` + # still aliases the user-authored descendants so root-level walkers + # (e.g. ``Form._get_form_refs``) can introspect the real subtree, but + # we skip the O(n) deepcopy + recursive style pass. Descendants are + # rendered AND styled in the page scope, not here, so only the root + # needs app-level style merged. + render = copy.copy(definition.component) + _apply_root_style(render) + + hooks = _root_only_hooks(render) + custom_code = _root_only_custom_code(render) + dynamic_imports = _root_only_dynamic_imports(render) + # Strings returned by the root's ``add_hooks`` can reference symbols + # (``refs``, ``StateContexts``, etc.) that normally reach this module + # through descendants' ``_get_hooks_imports`` / ``_get_imports``. JS + # imports are side-effect-free and dedup cleanly, so pulling the + # whole subtree's imports here is safe even when some go unused. + # ``_get_all_imports`` is read-only on the descendants, so the shallow + # aliasing above is fine. + all_imports = render._get_all_imports() + + # Swap children for JSX render: the memo body template emits a + # ``{children}`` hole in place of the real descendants. + render.children = [hole_child] + rendered = render.render() + else: + render = _apply_component_style_for_compile(copy.deepcopy(definition.component)) + rendered = render.render() + hooks = render._get_all_hooks() + custom_code = render._get_all_custom_code() + dynamic_imports = render._get_all_dynamic_imports() + all_imports = render._get_all_imports() + + # Each experimental memo now lives in ``web/utils/components/.jsx``, + # so importing the ``$/utils/components`` index from this file is only + # circular when ```` itself appears in that index — i.e. a legacy + # ``@rx.memo`` wrapper file. For auto-memo wrappers around legacy custom + # components, the index import is legitimate and must be preserved. + self_module = f"$/{constants.Dirs.COMPONENTS_PATH}/{definition.export_name}" imports: ParsedImportDict = { - lib: fields - for lib, fields in render._get_all_imports().items() - if lib != f"$/{constants.Dirs.COMPONENTS_PATH}" + lib: fields for lib, fields in all_imports.items() if lib != self_module } imports.setdefault("@emotion/react", []).append(ImportVar("jsx")) @@ -424,15 +497,69 @@ def compile_experimental_component_memo( fields=tuple(signature_fields), rest=rest_param.placeholder_name if rest_param is not None else None, ).to_javascript(), - "render": render.render(), - "hooks": render._get_all_hooks(), - "custom_code": render._get_all_custom_code(), - "dynamic_imports": render._get_all_dynamic_imports(), + "render": rendered, + "hooks": hooks, + "custom_code": custom_code, + "dynamic_imports": dynamic_imports, }, imports, ) +def _root_only_hooks(component: Component) -> dict[str, VarData | None]: + """Return hooks contributed by ``component`` itself, not its subtree. + + Used by the passthrough memo compile path where descendants render in the + page scope — only the wrapper's own hooks (internal + ``add_hooks`` + + explicit ``_get_hooks``) belong in the memo body. + + Args: + component: The root component whose own hooks to collect. + + Returns: + The root-level hook map, keyed by hook source string. + """ + code: dict[str, VarData | None] = {} + code.update(component._get_hooks_internal()) + explicit = component._get_hooks() + if explicit is not None: + code[explicit] = None + code.update(component._get_added_hooks()) + return code + + +def _root_only_custom_code(component: Component) -> dict[str, None]: + """Return custom code contributed by ``component`` itself, not its subtree. + + Args: + component: The root component whose own custom code to collect. + + Returns: + The root-level custom code snippets. + """ + code: dict[str, None] = {} + own = component._get_custom_code() + if own is not None: + code[own] = None + for clz in component._iter_parent_classes_with_method("add_custom_code"): + for item in clz.add_custom_code(component): + code[item] = None + return code + + +def _root_only_dynamic_imports(component: Component) -> set[str]: + """Return dynamic imports contributed by ``component`` itself. + + Args: + component: The root component whose own dynamic imports to collect. + + Returns: + The root-level dynamic imports. + """ + own = component._get_dynamic_imports() + return {own} if own else set() + + def compile_experimental_function_memo( definition: ExperimentalMemoFunctionDefinition, ) -> tuple[dict, ParsedImportDict]: @@ -446,10 +573,13 @@ def compile_experimental_function_memo( """ imports: ParsedImportDict = {} if var_data := definition.function._get_all_var_data(): + # Per-file memo modules live at ``$/utils/components/``; strip + # only a self-import to this function memo's own module. + self_module = f"$/{constants.Dirs.COMPONENTS_PATH}/{definition.python_name}" imports = { lib: list(fields) for lib, fields in dict(var_data.imports).items() - if lib != f"$/{constants.Dirs.COMPONENTS_PATH}" + if lib != self_module } return ( diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 118d894ed0a..f97c2f2c130 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -75,6 +75,14 @@ class ExperimentalMemoComponentDefinition(ExperimentalMemoDefinition): export_name: str component: Component + # For passthrough wrappers built by the auto-memoize plugin: the + # ``Bare``-wrapped ``{children}`` placeholder used when rendering the memo + # body. The ``component`` keeps its ORIGINAL children so compile-time + # walkers (``Form._get_form_refs`` etc.) can introspect the subtree; the + # compiler swaps to this placeholder only for the JSX render and for + # imports collection, so descendants emit their refs/imports/hooks in the + # page scope rather than being duplicated inside the memo body. + passthrough_hole_child: Component | None = None class ExperimentalMemoComponent(Component): @@ -342,7 +350,9 @@ def _imported_function_var(name: str, return_type: Any) -> FunctionVar: name, _var_type=ReflexCallable[Any, return_type], _var_data=VarData( - imports={f"$/{constants.Dirs.COMPONENTS_PATH}": [ImportVar(tag=name)]} + imports={ + f"$/{constants.Dirs.COMPONENTS_PATH}/{name}": [ImportVar(tag=name)] + } ), ) @@ -361,7 +371,7 @@ def _component_import_var(name: str) -> Var: _var_type=type[Component], _var_data=VarData( imports={ - f"$/{constants.Dirs.COMPONENTS_PATH}": [ImportVar(tag=name)], + f"$/{constants.Dirs.COMPONENTS_PATH}/{name}": [ImportVar(tag=name)], "@emotion/react": [ImportVar(tag="jsx")], } ), @@ -1023,10 +1033,18 @@ def create_passthrough_component_memo( # template. snapshot_only = is_snapshot_boundary(component) + captured_hole_child: list[Component] = [] + def passthrough(children: Var[Component]) -> Component: new_component = copy(component) - if not snapshot_only: - new_component.children = [Bare.create(children)] + if snapshot_only: + return new_component + # Keep ``new_component.children`` as the ORIGINAL children so + # compile-time walkers that introspect the subtree (e.g. Form's + # ``_get_form_refs``) see the real descendants. The ``{children}`` + # hole lives on the definition and the compiler swaps it in only for + # JSX render / imports collection. + captured_hole_child.append(Bare.create(children)) return new_component passthrough.__name__ = format.to_snake_case(export_name) @@ -1034,8 +1052,13 @@ def passthrough(children: Var[Component]) -> Component: passthrough.__module__ = __name__ definition = _create_component_definition(passthrough, Component) + replacements: dict[str, Any] = {} if definition.export_name != export_name: - definition = dataclasses.replace(definition, export_name=export_name) + replacements["export_name"] = export_name + if captured_hole_child: + replacements["passthrough_hole_child"] = captured_hole_child[0] + if replacements: + definition = dataclasses.replace(definition, **replacements) return _create_component_wrapper(definition), definition diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index e891bd5431f..9ca3495a2a2 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -1763,17 +1763,13 @@ class Inner(Component): tag = "Inner" library = "inner" - class Other(Component): - tag = "Other" - library = "other" - @rx.memo def wrapper(): return Inner.create() @rx.memo - def outer(c: Component): - return Other.create(c) + def outer(): + return wrapper() custom_comp = wrapper() @@ -1787,16 +1783,16 @@ def outer(c: Component): assert "inner" in imports_inner assert "outer" not in imports_inner - outer_comp = outer(c=wrapper()) + outer_comp = outer() - # Libraries are not imported directly, but are imported by the custom component. + # Nested custom components are only imported during compilation. assert "inner" not in outer_comp._get_all_imports() - assert "other" not in outer_comp._get_all_imports() # The imports are only resolved during compilation. _, imports_outer = compile_custom_component(outer_comp) assert "inner" not in imports_outer - assert "other" in imports_outer + assert "$/utils/components" in imports_outer + assert imports_outer["$/utils/components"] == [ImportVar(tag="Wrapper")] def test_custom_component_declare_event_handlers_in_fields(): From 9d626bcc89e36b13da5b00c9cff274c365de4dd0 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 16:42:01 +0500 Subject: [PATCH 40/59] Rebuild passthrough auto-memo definitions per compile Stop caching passthrough auto-memo definitions globally by tag. Those definitions capture app-specific component trees and can leak stale event bindings across compiles. Rebuild the definition each time `_build_wrapper()` runs and add a regression test to ensure repeated tags across compile contexts do not reuse the same definition. --- pyi_hashes.json | 120 +++++++++++++++++++- reflex/compiler/plugins/memoize.py | 24 +--- tests/units/compiler/test_memoize_plugin.py | 54 ++++++++- 3 files changed, 175 insertions(+), 23 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index e5b39bd2505..5d84567f9ac 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,123 @@ { - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "d74bda907214feebebd9a71ea590cf34", + "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": "8e379fa038c7c6c0672639eb5902934d", + "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": "31da62c4d8c1d459089aab32cd232feb", + "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": "ff331d6a8c5dc718ee481fdc06f5d4cf" diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 74b1349a1f6..7ac21a11e3c 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -142,26 +142,6 @@ def _should_memoize(component: Component) -> bool: return bool(component.event_triggers) -_KNOWN_MEMO_TAGS: dict[str, tuple[Any, Any]] = {} - - -def _get_passthrough_memo_component(tag: str, component: Component) -> tuple[Any, Any]: - """Return the generated experimental memo wrapper callable and definition. - - Args: - tag: The wrapper's exported component name. - component: The component to wrap. - - Returns: - The memo wrapper callable and its definition. - """ - if tag in _KNOWN_MEMO_TAGS: - return _KNOWN_MEMO_TAGS[tag] - memo_wrapper, memo_definition = create_passthrough_component_memo(tag, component) - _KNOWN_MEMO_TAGS[tag] = (memo_wrapper, memo_definition) - return memo_wrapper, memo_definition - - @dataclasses.dataclass(frozen=True, slots=True) class MemoizeStatefulPlugin(Plugin): """Auto-memoize stateful components with experimental-memo wrappers. @@ -310,7 +290,9 @@ def _build_wrapper( comp = fix_event_triggers_for_memo(comp, page_context) compile_context.memoize_wrappers[tag] = None - wrapper_factory, definition = _get_passthrough_memo_component(tag, comp) + # Passthrough memo definitions capture app-specific event/state vars, so + # they must be rebuilt for each compile instead of shared globally. + wrapper_factory, definition = create_passthrough_component_memo(tag, comp) compile_context.auto_memo_components[tag] = definition return wrapper_factory() diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 50419cf7f64..f25b53f0d7d 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -2,7 +2,8 @@ import dataclasses from collections.abc import Callable -from typing import Any +from types import SimpleNamespace +from typing import Any, cast from reflex_base.components.component import Component, field from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode @@ -12,6 +13,7 @@ from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment +import reflex.compiler.plugins.memoize as memoize_plugin from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin, _should_memoize from reflex.experimental.memo import ( @@ -193,6 +195,56 @@ def test_generated_memo_component_renders_as_its_exported_tag() -> None: assert wrapper.render()["name"] == "MyWrapper_abc" +def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None: + """Repeated tags across compiles rebuild their passthrough definitions. + + Regression: sharing auto-memo definitions globally by tag leaks the first + app's captured component tree into later compiles, which can stale-bind + state event names across AppHarness apps. + """ + tag = "SharedMemoTag" + first_component = Plain.create(STATE_VAR) + second_component = Plain.create(STATE_VAR) + + monkeypatch.setattr(memoize_plugin, "_compute_memo_tag", lambda comp: tag) + monkeypatch.setattr( + memoize_plugin, + "fix_event_triggers_for_memo", + lambda comp, page_context: comp, + ) + + def fake_create_passthrough_component_memo(export_name: str, component: Component): + definition = SimpleNamespace(export_name=export_name, component=component) + return (lambda definition=definition: definition), definition + + monkeypatch.setattr( + memoize_plugin, + "create_passthrough_component_memo", + fake_create_passthrough_component_memo, + ) + + first_compile = SimpleNamespace(memoize_wrappers={}, auto_memo_components={}) + second_compile = SimpleNamespace(memoize_wrappers={}, auto_memo_components={}) + page_context = cast(PageContext, SimpleNamespace()) + + MemoizeStatefulPlugin._build_wrapper( + first_component, + page_context=page_context, + compile_context=first_compile, + ) + MemoizeStatefulPlugin._build_wrapper( + second_component, + page_context=page_context, + compile_context=second_compile, + ) + + first_definition = first_compile.auto_memo_components[tag] + second_definition = second_compile.auto_memo_components[tag] + assert first_definition.component is first_component + assert second_definition.component is second_component + assert second_definition is not first_definition + + def test_shared_subtree_across_pages_uses_same_tag() -> None: """The same memoizable subtree on multiple pages gets one shared tag.""" ctx = CompileContext( From 63b3a40b1e65e8a867165b5ce488cbc4753e3c56 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 23 Apr 2026 19:28:27 +0500 Subject: [PATCH 41/59] feat: add PRE/NORMAL/POST ordering for compiler component hooks Introduce HookOrder buckets so plugins can declare when their enter_component/leave_component hooks run relative to others. Move the default collector to POST so it observes leave-time replacements (e.g. memoize wrappers) in imports, custom code, and output. Rely on the walker to visit prop-tree components instead of recursing inside the collector. --- .../src/reflex_base/plugins/base.py | 19 ++- .../src/reflex_base/plugins/compiler.py | 40 +++--- reflex/compiler/plugins/builtin.py | 54 ++------ reflex/compiler/plugins/memoize.py | 3 - tests/units/compiler/test_plugins.py | 120 ++++++++++++++++++ 5 files changed, 174 insertions(+), 62 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index de4ad381e20..082258ddb9c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -1,11 +1,21 @@ """Base class for all plugins.""" from collections.abc import Callable, Sequence +from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, ParamSpec, Protocol, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, ParamSpec, Protocol, TypedDict from typing_extensions import Unpack + +class HookOrder(str, Enum): + """Dispatch bucket for a compiler ``enter_component`` / ``leave_component`` hook.""" + + PRE = "pre" + NORMAL = "normal" + POST = "post" + + if TYPE_CHECKING: from reflex.app import App, UnevaluatedPage from reflex_base.components.component import BaseComponent @@ -56,6 +66,13 @@ class PostCompileContext(CommonContext): class Plugin: """Base class for all plugins.""" + # Dispatch position for ``enter_component`` and ``leave_component`` hooks. + # Plugins run in ``PRE`` → ``NORMAL`` → ``POST`` order. Within a bucket, + # enter hooks fire in plugin-chain order while leave hooks fire in + # reverse plugin-chain order (mirroring an enter/leave stack). + _compiler_enter_component_order: ClassVar[HookOrder] = HookOrder.NORMAL + _compiler_leave_component_order: ClassVar[HookOrder] = HookOrder.NORMAL + def get_frontend_development_dependencies( self, **context: Unpack[CommonContext] ) -> list[str] | set[str] | tuple[str, ...]: diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 575a576b111..ecb55a03d92 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -16,7 +16,7 @@ from reflex_base.utils.imports import ParsedImportDict, collapse_imports, merge_imports from reflex_base.vars import VarData -from .base import Plugin +from .base import HookOrder, Plugin if TYPE_CHECKING: from reflex.app import App, ComponentCallable @@ -103,48 +103,49 @@ def __post_init__(self) -> None: "_compile_page_hooks", self._resolve_hooks("compile_page"), ) - enter_hook_binders: list[EnterHookBinder] = [] - leave_hook_binders: list[LeaveHookBinder] = [] + enter_buckets: dict[HookOrder, list[EnterHookBinder]] = { + order: [] for order in HookOrder + } + leave_buckets: dict[HookOrder, list[LeaveHookBinder]] = { + order: [] for order in HookOrder + } component_hooks_can_replace = False for plugin in self.plugins: + plugin_type = type(plugin) if ( hook_impl := self._get_hook_impl(plugin, "enter_component") ) is not None: - enter_hook_binders.append( + enter_buckets[plugin_type._compiler_enter_component_order].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, - ) + getattr(plugin_type, "_compiler_can_replace_enter_component", True) ) if ( hook_impl := self._get_hook_impl(plugin, "leave_component") ) is not None: - leave_hook_binders.append( + leave_buckets[plugin_type._compiler_leave_component_order].append( self._get_leave_hook_binder(plugin, hook_impl) ) component_hooks_can_replace = component_hooks_can_replace or bool( - getattr( - type(plugin), - "_compiler_can_replace_leave_component", - True, - ) + getattr(plugin_type, "_compiler_can_replace_leave_component", True) ) object.__setattr__( self, "_enter_component_hook_binders", - tuple(enter_hook_binders), + tuple(binder for order in HookOrder for binder in enter_buckets[order]), ) object.__setattr__( self, "_leave_component_hook_binders", - tuple(reversed(tuple(leave_hook_binders))), + tuple( + binder + for order in HookOrder + for binder in reversed(leave_buckets[order]) + ), ) object.__setattr__( self, @@ -534,6 +535,11 @@ def visit( ) if replacement_children is not compiled_children: assert replacement_children is not None + # Re-walking fires enter/leave again on any child objects + # carried over from the original children tuple. Observing + # collectors dedupe by dict key, so this is idempotent for + # today's plugins; stateful side effects on the page + # context would be double-applied. compiled_children = visit_children( replacement_children, current_in_prop_tree, diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 30e607f297b..a4b326be4ab 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -9,6 +9,7 @@ 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.plugins.base import HookOrder 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 @@ -70,7 +71,6 @@ def eval_page( class ApplyStylePlugin(Plugin): """Apply app-level styles in the descending phase of the walk.""" - _compiler_can_replace_enter_component = True style: ComponentStyle | None = None @staticmethod @@ -167,7 +167,9 @@ def enter_component( class DefaultCollectorPlugin(Plugin): """Collect page artifacts in one fused enter/leave hook pair.""" - _compiler_can_replace_enter_component = False + # Run after replacing leave hooks so collected imports/custom-code reflect + # the final post-replacement component (e.g. memoize wrappers). + _compiler_leave_component_order = HookOrder.POST _compiler_can_replace_leave_component = False def leave_component( @@ -188,9 +190,9 @@ def leave_component( if imports: self._extend_imports(page_context.frontend_imports, imports) - if not in_prop_tree: - self._collect_component_custom_code(page_context.module_code, comp) + self._collect_component_custom_code(page_context.module_code, comp) + if not in_prop_tree: self._collect_component_hooks(page_context.hooks, comp) if ( @@ -266,9 +268,9 @@ def leave_component( if imports_for_component: extend_imports(frontend_imports, imports_for_component) - if not in_prop_tree: - collect_component_custom_code(module_code, comp) + collect_component_custom_code(module_code, comp) + if not in_prop_tree: collect_component_hooks(hooks, comp) app_wrap_method = type(comp)._get_app_wrap_components @@ -314,49 +316,19 @@ def _collect_component_custom_code( module_code: dict[str, None], component: Component, ) -> 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, - ) - - 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], - ) -> None: - """Recursively collect prop-tree custom code directly into ``module_code``.""" - if not isinstance(component, Component): - module_code.update(component._get_all_custom_code()) - return + """Collect custom code contributed directly by one component. + The compiler walker visits every structural child and every component + in prop subtrees, firing ``leave_component`` on each — so this helper + only handles the current node and does not recurse. + """ 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, - ) - 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, - ) - def _collect_app_wrap_components( self, page_app_wrap_components: dict[tuple[int, str], Component], diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 7ac21a11e3c..831d5f91870 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -165,9 +165,6 @@ class MemoizeStatefulPlugin(Plugin): component leaves. """ - _compiler_can_replace_enter_component = True - _compiler_can_replace_leave_component = True - def enter_component( self, comp: BaseComponent, diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index c960f5366b0..26eb1f39c99 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -20,6 +20,7 @@ PageDefinition, Plugin, ) +from reflex_base.plugins.base import HookOrder from reflex_base.utils import format as format_utils from reflex_base.utils.imports import ImportVar, collapse_imports, merge_imports from reflex_base.vars import VarData @@ -124,6 +125,14 @@ class InlineStatefulComponent(Component): library = "inline-lib" +class ReplacementComponent(Component): + tag = "ReplacementComponent" + library = "replacement-lib" + + def _get_custom_code(self) -> str | None: + return "const replacementCustomCode = 1;" + + class StubPlugin(Plugin): pass @@ -760,6 +769,25 @@ def test_default_collector_matches_legacy_collectors() -> None: ) +def test_default_collector_collects_nested_prop_tree_custom_code_without_recursion() -> ( + None +): + component = RootComponent.create( + slot=PropComponent.create( + ChildComponent.create(), + ) + ) + + page_ctx = collect_page_context( + component, + plugins=(DefaultCollectorPlugin(),), + ) + + assert page_ctx.module_code == component._get_all_custom_code() + assert "const propCustomCode = 1;" in page_ctx.module_code + assert "const childCustomCode = 1;" in page_ctx.module_code + + def test_default_page_plugins_are_minimal_and_ordered() -> None: from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin @@ -772,6 +800,98 @@ def test_default_page_plugins_are_minimal_and_ordered() -> None: assert isinstance(plugins[3], MemoizeStatefulPlugin) +def test_compile_context_collects_artifacts_from_leave_replacement_plugins() -> None: + page = FakePage(route="/replacement", component=create_component_tree) + + class ReplaceRootPlugin(StubPlugin): + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + ) -> BaseComponent | None: + del page_context, compile_context, in_prop_tree + if isinstance(comp, RootComponent): + return ReplacementComponent.create(*children) + return None + + compile_ctx = CompileContext( + pages=[page], + hooks=CompilerHooks( + plugins=default_page_plugins(plugins=(ReplaceRootPlugin(),)) + ), + ) + + with compile_ctx: + compile_ctx.compile() + + page_ctx = compile_ctx.compiled_pages["/replacement"] + assert ( + page_ctx.root_component.render()["children"][0]["name"] + == "ReplacementComponent" + ) + assert "replacement-lib" in page_ctx.frontend_imports + assert "root-lib" not in page_ctx.frontend_imports + assert "const replacementCustomCode = 1;" in page_ctx.module_code + assert "const rootAddedCode = 1;" not in page_ctx.module_code + assert ("import {" + 'ReplacementComponent} from "replacement-lib"') in ( + page_ctx.output_code or "" + ) + assert ("import {" + 'RootComponent} from "root-lib"') not in ( + page_ctx.output_code or "" + ) + + +def test_leave_component_order_dispatches_pre_normal_post() -> None: + calls: list[str] = [] + + class LabelledLeavePlugin(StubPlugin): + label: str = "" + + def leave_component( + self, + comp: BaseComponent, + children: tuple[BaseComponent, ...], + /, + *, + page_context: PageContext, + compile_context: CompileContext, + in_prop_tree: bool = False, + ) -> None: + del children, page_context, compile_context, in_prop_tree + if isinstance(comp, RootComponent): + calls.append(self.label) + + class PrePlugin(LabelledLeavePlugin): + _compiler_leave_component_order = HookOrder.PRE + label = "pre" + + class NormalPlugin(LabelledLeavePlugin): + label = "normal" + + class PostPlugin(LabelledLeavePlugin): + _compiler_leave_component_order = HookOrder.POST + label = "post" + + component = create_component_tree() + hooks = CompilerHooks(plugins=(PostPlugin(), NormalPlugin(), PrePlugin())) + 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 calls == ["pre", "normal", "post"] + + def test_compile_context_compiles_pages_and_matches_legacy_output() -> None: page = FakePage(route="/demo", component=create_component_tree) compile_ctx = CompileContext( From 8aa127583cdda3ab2168a5d9d9d075cfd7041276 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 24 Apr 2026 19:00:52 +0500 Subject: [PATCH 42/59] Fix auto-memo snapshot handling for structural forms Consolidate auto-memo render strategy classification so snapshot bodies are used for MemoizationLeaf components and structural Foreach/Cond/Match forms, while passthrough components like forms keep their children hole for ref collection. Add regression tests proving special-form state hooks render inside generated memo components instead of leaking into the page, and update the automemo_upload example to validate stateful, upload, form, and special-form memoization output. --- .../reflex_base/components/memoize_helpers.py | 83 ++++++++++++- pyi_hashes.json | 2 +- reflex/compiler/plugins/memoize.py | 86 +++++-------- reflex/experimental/memo.py | 11 +- tests/units/compiler/test_memoize_plugin.py | 116 +++++++++++++++++- 5 files changed, 239 insertions(+), 59 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 14dd4bc14c1..657e0c30815 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -1,14 +1,25 @@ -"""Event-trigger memoization helpers for auto-memoized and pseudo-stateful components. +"""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``). + +Auto-memoized components compile using one of two render strategies: + +- Passthrough memo bodies render the root component with a ``{children}`` hole. + The page still renders the descendants, which keeps root-level introspection + such as ``Form._get_form_refs`` working against the authored child tree. +- Snapshot memo bodies render the captured subtree in the memo module. This is + required for non-recursive memoization leaves and structural forms + (``Foreach``/``Cond``/``Match``) whose stateful render logic belongs inside + the memo component rather than the containing page. """ from __future__ import annotations +import enum from hashlib import md5 from typing import TYPE_CHECKING @@ -23,6 +34,13 @@ from reflex_base.plugins.compiler import PageContext +class MemoizationStrategy(enum.Enum): + """How an auto-memo wrapper should render a component if it is memoized.""" + + PASSTHROUGH = "passthrough" + SNAPSHOT = "snapshot" + + def _get_hook_deps(hook: str) -> list[str]: """Extract Var deps from a hook declaration line. @@ -180,8 +198,71 @@ def is_snapshot_boundary(component: Component) -> bool: return not component._memoization_mode.recursive +def _is_structural_memoization_child(component: Component) -> bool: + """Check whether ``component`` is a structural child for memoization. + + Args: + component: The child component to inspect. + + Returns: + True when the component's render body must stay inside the generated + memo body rather than flowing through as a normal ``children`` payload. + """ + 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(component, Foreach): + return True + if isinstance(component, (Cond, Match)): + return bool(component.cond._get_all_var_data()) + return False + + +def _has_memoization_snapshot_child(component: Component) -> bool: + """Whether ``component`` has a structural child that needs a memo snapshot. + + Component-valued ``Foreach``, ``Cond``, and ``Match`` are structural forms, + not ordinary user children. When they read state, the generated passthrough + memo must render their body in the memo module; otherwise state wiring + leaks into the page and the memo body degrades to just ``children``. + + Args: + component: The component whose direct children should be inspected. + + Returns: + True when a direct child requires the parent memo wrapper to render a + captured snapshot. + """ + return any( + isinstance(child, Component) and _is_structural_memoization_child(child) + for child in component.children + ) + + +def get_memoization_strategy(component: Component) -> MemoizationStrategy: + """Get the render strategy for ``component`` if auto-memoization wraps it. + + Args: + component: The component being considered by auto-memoization. + + Returns: + The strategy to use when generating a memo wrapper. + """ + if ( + is_snapshot_boundary(component) + or _is_structural_memoization_child(component) + or _has_memoization_snapshot_child(component) + ): + return MemoizationStrategy.SNAPSHOT + + return MemoizationStrategy.PASSTHROUGH + + __all__ = [ + "MemoizationStrategy", "fix_event_triggers_for_memo", + "get_memoization_strategy", "get_memoized_event_triggers", "is_snapshot_boundary", ] diff --git a/pyi_hashes.json b/pyi_hashes.json index 5d84567f9ac..0130f319365 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "ff331d6a8c5dc718ee481fdc06f5d4cf" + "reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e" } diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 831d5f91870..b889ccde22c 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -12,7 +12,7 @@ - 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. + the generated memo body so handler hooks stay inside that rendering domain. No shared ``stateful_components`` file is produced. """ @@ -29,43 +29,19 @@ _hash_str, ) from reflex_base.components.memoize_helpers import ( + MemoizationStrategy, fix_event_triggers_for_memo, + get_memoization_strategy, is_snapshot_boundary, ) 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 -def _child_var(child: Component) -> Var | Component: - """Return the core Var of a structural child, for memoize-eligibility checks. - - For special wrappers (``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.core.cond import Cond - from reflex_components_core.core.foreach import Foreach - from reflex_components_core.core.match import Match - - 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. @@ -109,7 +85,8 @@ def _should_memoize(component: Component) -> bool: True if the component should be wrapped in a memo definition. """ from reflex_components_core.base.bare import Bare - from reflex_components_core.core.foreach import Foreach + + strategy = get_memoization_strategy(component) if component._memoization_mode.disposition == MemoizationDisposition.NEVER: return False @@ -126,17 +103,11 @@ def _should_memoize(component: Component) -> bool: if prop_var._get_all_var_data(): return True - # Special-case structural children that are Var wrappers (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 + # Snapshot-strategy non-boundaries (structural forms or their parents) + # must memoize so the state-dependent render logic lands inside the memo + # body instead of the page. + if strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component): + return True # Components with event triggers are always memoized (to wrap callbacks). return bool(component.event_triggers) @@ -147,16 +118,16 @@ class MemoizeStatefulPlugin(Plugin): """Auto-memoize stateful components with experimental-memo wrappers. Registered in ``default_page_plugins`` before ``DefaultCollectorPlugin``. - Two memoization modes, driven by whether the component is a snapshot - boundary (see ``is_snapshot_boundary``): + Components either render as passthrough memo wrappers or snapshot memo + wrappers (see ``get_memoization_strategy``): - - Snapshot boundaries (``MemoizationLeaf``-style): wrapped in - ``enter_component`` and returned with empty structural children. The - walker skips descent, so hooks attached to the leaf's internal children - are captured in the memo body only — never hoisted into the page scope. - - Non-leaf memoizable components: wrapped in ``leave_component`` after - descendants have already compiled, so any inner memo wrappers flow into - this wrapper's children. + - Snapshot wrappers (``MemoizationLeaf``-style boundaries and structural + ``Foreach``/``Cond``/``Match`` wrappers): wrapped in ``enter_component`` + and returned with empty structural children. The walker skips descent, so + hooks attached to the captured body are compiled into the memo body only. + - Passthrough wrappers: wrapped in ``leave_component`` after descendants + have already compiled, so any inner memo wrappers flow into this wrapper's + children. Descendants of a snapshot boundary are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked @@ -205,16 +176,23 @@ def enter_component( return None if page_context.memoize_suppressor_stack: return None - if not is_snapshot_boundary(comp): + strategy = get_memoization_strategy(comp) + if strategy is not MemoizationStrategy.SNAPSHOT: return None + snapshot_boundary = is_snapshot_boundary(comp) if not _should_memoize(comp): # Boundary not worth wrapping — still suppress descendants so # they don't memoize independently of the boundary's subtree. - page_context.memoize_suppressor_stack.append(id(comp)) + if snapshot_boundary: + page_context.memoize_suppressor_stack.append(id(comp)) return None - wrapper = self._build_wrapper(comp, page_context, compile_context) + wrapper = self._build_wrapper( + comp, + page_context, + compile_context, + ) return None if wrapper is None else (wrapper, ()) def leave_component( @@ -252,7 +230,7 @@ def leave_component( if stack: return None - if is_snapshot_boundary(comp): + if get_memoization_strategy(comp) is MemoizationStrategy.SNAPSHOT: return None if not _should_memoize(comp): @@ -262,7 +240,9 @@ def leave_component( @staticmethod def _build_wrapper( - comp: Component, page_context: PageContext, compile_context: Any + comp: Component, + page_context: PageContext, + compile_context: Any, ) -> BaseComponent | None: """Return the memo wrapper component for ``comp``, or ``None`` if untagged. diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index f97c2f2c130..0dbf91bfbf4 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -12,7 +12,10 @@ from reflex_base import constants from reflex_base.components.component import Component from reflex_base.components.dynamic import bundled_libraries -from reflex_base.components.memoize_helpers import is_snapshot_boundary +from reflex_base.components.memoize_helpers import ( + MemoizationStrategy, + get_memoization_strategy, +) from reflex_base.constants.compiler import ( MemoizationDisposition, MemoizationMode, @@ -1031,13 +1034,15 @@ def create_passthrough_component_memo( # the boundary with no structural children on the page side, so the memo # body renders the full snapshot rather than a ``{children}``-holed # template. - snapshot_only = is_snapshot_boundary(component) + render_snapshot = ( + get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT + ) captured_hole_child: list[Component] = [] def passthrough(children: Var[Component]) -> Component: new_component = copy(component) - if snapshot_only: + if render_snapshot: return new_component # Keep ``new_component.children`` as the ORIGINAL children so # compile-time walkers that introspect the subtree (e.g. Form's diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index f25b53f0d7d..12e5fa9e590 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -5,7 +5,12 @@ from types import SimpleNamespace from typing import Any, cast +import pytest from reflex_base.components.component import Component, field +from reflex_base.components.memoize_helpers import ( + MemoizationStrategy, + get_memoization_strategy, +) from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.vars import VarData @@ -13,6 +18,7 @@ from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment +import reflex as rx import reflex.compiler.plugins.memoize as memoize_plugin from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin, _should_memoize @@ -20,6 +26,7 @@ ExperimentalMemoComponent, create_passthrough_component_memo, ) +from reflex.state import BaseState STATE_VAR = LiteralVar.create("value")._replace( merge_var_data=VarData(hooks={"useTestState": None}, state="TestState") @@ -44,6 +51,12 @@ class LeafComponent(Component): _memoization_mode = MemoizationMode(recursive=False) +class SpecialFormMemoState(BaseState): + items: list[str] = ["a"] + flag: bool = True + value: str = "a" + + @dataclasses.dataclass(slots=True) class FakePage: route: str @@ -132,6 +145,104 @@ def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None: ) == 1 +@pytest.mark.parametrize( + ("special_form", "body_marker"), + [ + ("foreach", "Array.prototype.map.call"), + ("cond", "flag_rx_state_?"), + ("match", "switch (JSON.stringify"), + ], +) +def test_special_form_memo_wrappers_render_structural_body( + special_form: str, + body_marker: str, +) -> None: + """Generated memo wrappers for special forms render the structural body. + + The memo body must subscribe to the state the special form references + (via ``useContext(StateContexts...)``), and the page must not — otherwise + the state-dependent render has leaked into page scope. + """ + from reflex.compiler.compiler import compile_memo_components + + def special_child() -> Component: + if special_form == "foreach": + return rx.foreach( + SpecialFormMemoState.items, + lambda item: rx.text(item), + ) + if special_form == "cond": + return cast( + Component, + rx.cond( + SpecialFormMemoState.flag, + rx.text("yes"), + rx.text("no"), + ), + ) + return cast( + Component, + rx.match( + SpecialFormMemoState.value, + ("a", rx.text("A")), + rx.text("default"), + ), + ) + + ctx, page_ctx = _compile_single_page(lambda: rx.box(special_child())) + + memo_files, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + memo_code = "\n".join(code for _, code in memo_files) + + state_wiring = "useContext(StateContexts" + assert state_wiring in memo_code + assert state_wiring not in (page_ctx.output_code or "") + assert body_marker in memo_code + assert body_marker not in (page_ctx.output_code or "") + + +def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: + """The shared memoization strategy covers leaves and structural forms.""" + from reflex_components_core.el.elements.forms import Form, Input + + foreach_parent = rx.box( + rx.foreach( + SpecialFormMemoState.items, + lambda item: rx.text(item), + ) + ) + cond_fragment = cast( + Component, + rx.cond( + SpecialFormMemoState.flag, + rx.text("yes"), + rx.text("no"), + ), + ) + match_fragment = cast( + Component, + rx.match( + SpecialFormMemoState.value, + ("a", rx.text("A")), + rx.text("default"), + ), + ) + + assert get_memoization_strategy(foreach_parent) is MemoizationStrategy.SNAPSHOT + assert get_memoization_strategy(cond_fragment) is MemoizationStrategy.SNAPSHOT + assert get_memoization_strategy(match_fragment) is MemoizationStrategy.SNAPSHOT + assert ( + get_memoization_strategy(LeafComponent.create(Plain.create())) + is MemoizationStrategy.SNAPSHOT + ) + + form = Form.create(Input.create(name="username", id="username")) + assert get_memoization_strategy(form) is MemoizationStrategy.PASSTHROUGH + + def test_memoization_leaf_suppresses_descendant_wrapping() -> None: """A MemoizationLeaf suppresses independent wrappers for its descendants. @@ -213,7 +324,10 @@ def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> No lambda comp, page_context: comp, ) - def fake_create_passthrough_component_memo(export_name: str, component: Component): + def fake_create_passthrough_component_memo( + export_name: str, + component: Component, + ): definition = SimpleNamespace(export_name=export_name, component=component) return (lambda definition=definition: definition), definition From f95a6429e604d720d79dee93ee67d183ff8f8546 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 28 Apr 2026 13:12:52 -0700 Subject: [PATCH 43/59] Separately memoize Cond and Match children (#11) When Cond or Match cases contain stateful children, auto-memoize these separately from the component to avoid unnecessary mixing of hooks. --- .../reflex_base/components/memoize_helpers.py | 27 +- .../src/reflex_components_core/core/cond.py | 20 +- .../src/reflex_components_core/core/match.py | 32 +- pyi_hashes.json | 4 + reflex/compiler/plugins/memoize.py | 41 ++- tests/integration/test_var_operations.py | 14 + tests/units/compiler/test_memoize_plugin.py | 281 +++++++++++++++++- tests/units/components/core/test_cond.py | 13 + 8 files changed, 393 insertions(+), 39 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 657e0c30815..1d5b91456d5 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -13,8 +13,8 @@ such as ``Form._get_form_refs`` working against the authored child tree. - Snapshot memo bodies render the captured subtree in the memo module. This is required for non-recursive memoization leaves and structural forms - (``Foreach``/``Cond``/``Match``) whose stateful render logic belongs inside - the memo component rather than the containing page. + (``Foreach``) whose stateful render logic belongs inside the memo component + rather than the containing page. """ from __future__ import annotations @@ -208,24 +208,17 @@ def _is_structural_memoization_child(component: Component) -> bool: True when the component's render body must stay inside the generated memo body rather than flowing through as a normal ``children`` payload. """ - 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(component, Foreach): - return True - if isinstance(component, (Cond, Match)): - return bool(component.cond._get_all_var_data()) - return False + return bool(isinstance(component, Foreach)) def _has_memoization_snapshot_child(component: Component) -> bool: """Whether ``component`` has a structural child that needs a memo snapshot. - Component-valued ``Foreach``, ``Cond``, and ``Match`` are structural forms, - not ordinary user children. When they read state, the generated passthrough - memo must render their body in the memo module; otherwise state wiring - leaks into the page and the memo body degrades to just ``children``. + Component-valued ``Foreach`` is a structural form, not an ordinary user + child. It must render its body in the memo module instead of flowing + through as a normal ``children`` payload. Args: component: The component whose direct children should be inspected. @@ -249,6 +242,14 @@ def get_memoization_strategy(component: Component) -> MemoizationStrategy: Returns: The strategy to use when generating a memo wrapper. """ + from reflex_components_core.core.match import Match + + # Match compiles branch returns into switch/case-like code from explicit + # children, so it cannot use a single passthrough {children} hole in memo + # compilation. + if isinstance(component, Match): + return MemoizationStrategy.SNAPSHOT + if ( is_snapshot_boundary(component) or _is_structural_memoization_child(component) diff --git a/packages/reflex-components-core/src/reflex_components_core/core/cond.py b/packages/reflex-components-core/src/reflex_components_core/core/cond.py index 98ba56d170d..bda854c5743 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/cond.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/cond.py @@ -26,6 +26,16 @@ class Cond(Component): cond: Var[Any] = field(doc="The cond to determine which component to render.") + def _get_cond_children(self) -> tuple[BaseComponent, BaseComponent]: + """Get true and false branch components with safe defaults. + + Returns: + A tuple containing true and false branch components. + """ + true_child = self.children[0] if self.children else Fragment.create() + false_child = self.children[1] if len(self.children) > 1 else Fragment.create() + return true_child, false_child + @classmethod def create( cls, @@ -60,10 +70,11 @@ def create( ) def _render(self) -> Tag: + true_child, false_child = self._get_cond_children() return CondTag( cond_state=str(self.cond), - true_value=self.children[0].render(), - false_value=self.children[1].render(), + true_value=true_child.render(), + false_value=false_child.render(), ) def render(self) -> dict: @@ -72,10 +83,11 @@ def render(self) -> dict: Returns: The dictionary for template of component. """ + true_child, false_child = self._get_cond_children() return { "cond_state": str(self.cond), - "true_value": self.children[0].render(), - "false_value": self.children[1].render(), + "true_value": true_child.render(), + "false_value": false_child.render(), } def add_imports(self) -> ImportDict: diff --git a/packages/reflex-components-core/src/reflex_components_core/core/match.py b/packages/reflex-components-core/src/reflex_components_core/core/match.py index d062587b04c..c29ed7c112f 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/match.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/match.py @@ -3,12 +3,7 @@ import textwrap from typing import Any, cast -from reflex_base.components.component import ( - BaseComponent, - Component, - MemoizationLeaf, - field, -) +from reflex_base.components.component import BaseComponent, Component, field from reflex_base.components.tags import Tag from reflex_base.components.tags.match_tag import MatchTag from reflex_base.style import Style @@ -21,7 +16,7 @@ from reflex_components_core.base import Fragment -class Match(MemoizationLeaf): +class Match(Component): """Match cases based on a condition.""" cond: Var[Any] = field(doc="The condition to determine which case to match.") @@ -270,13 +265,32 @@ def _create_match_cond_var_or_component( ) def _render(self) -> Tag: + # Reconstruct match_cases and default from self.children, which may have + # been updated by the compiler walker to include memoized wrappers. + # self.children contains: [case_1_return, case_2_return, ..., default] + # self.match_cases contains the conditions as Vars. + num_cases = len(self.match_cases) + if len(self.children) != num_cases + 1: + msg = ( + f"Match children count mismatch: expected {num_cases + 1} " + f"(cases + default), got {len(self.children)}" + ) + raise ValueError(msg) + + cases_returns = self.children[:num_cases] + default_return = self.children[num_cases] + return MatchTag( cond=str(self.cond), match_cases=[ ([str(cond) for cond in conditions], return_value.render()) - for conditions, return_value in self.match_cases + for (conditions, _), return_value in zip( + self.match_cases, + cases_returns, + strict=True, + ) ], - default=self.default.render(), + default=default_return.render(), ) def render(self) -> dict: diff --git a/pyi_hashes.json b/pyi_hashes.json index 0130f319365..1fd3c60eb6d 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,6 +118,10 @@ "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", + "packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.pyi": "542ccba14de2456c1a046697982e0147", + "packages/reflex-site-shared/src/reflex_site_shared/components/image_zoom.pyi": "3999125aeb7c1768495659b20b033f54", + "packages/reflex-site-shared/src/reflex_site_shared/components/marketing_button.pyi": "74b01fba2002f202c07c005195c67dc8", + "packages/reflex-site-shared/src/reflex_site_shared/components/marquee.pyi": "596f0121f0bd409500da85cdd842a35d", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e" diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index b889ccde22c..1743dc5329e 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -85,6 +85,8 @@ def _should_memoize(component: Component) -> bool: True if the component should be wrapped in a memo definition. """ from reflex_components_core.base.bare import Bare + from reflex_components_core.core.cond import Cond + from reflex_components_core.core.match import Match strategy = get_memoization_strategy(component) @@ -93,7 +95,7 @@ def _should_memoize(component: Component) -> bool: if isinstance(component, Bare) and component.contents._get_all_var_data(): # A stateful value will be wrapped in a separate component. return True - if component.tag is None: + if component.tag is None and not isinstance(component, (Cond, Match)): return False if component._memoization_mode.disposition == MemoizationDisposition.ALWAYS: return True @@ -106,7 +108,11 @@ def _should_memoize(component: Component) -> bool: # Snapshot-strategy non-boundaries (structural forms or their parents) # must memoize so the state-dependent render logic lands inside the memo # body instead of the page. - if strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component): + if ( + strategy is MemoizationStrategy.SNAPSHOT + and not is_snapshot_boundary(component) + and not isinstance(component, Match) + ): return True # Components with event triggers are always memoized (to wrap callbacks). @@ -122,12 +128,15 @@ class MemoizeStatefulPlugin(Plugin): wrappers (see ``get_memoization_strategy``): - Snapshot wrappers (``MemoizationLeaf``-style boundaries and structural - ``Foreach``/``Cond``/``Match`` wrappers): wrapped in ``enter_component`` + ``Foreach`` wrappers): wrapped in ``enter_component`` and returned with empty structural children. The walker skips descent, so hooks attached to the captured body are compiled into the memo body only. - - Passthrough wrappers: wrapped in ``leave_component`` after descendants - have already compiled, so any inner memo wrappers flow into this wrapper's - children. + - Passthrough wrappers (including ``Cond``) are wrapped in + ``leave_component`` after descendants have already compiled, so any inner + memo wrappers flow into this wrapper's children. + - ``Match`` is classified as snapshot for memo rendering semantics, but is + handled specially to recurse during the page walk and wrap in + ``leave_component`` so case branches can still be memoized independently. Descendants of a snapshot boundary are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked @@ -179,6 +188,12 @@ def enter_component( strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None + from reflex_components_core.core.match import Match + + if isinstance(comp, Match): + # Match needs snapshot memo body rendering but still must recurse so + # stateful case components can be memoized independently. + return None snapshot_boundary = is_snapshot_boundary(comp) if not _should_memoize(comp): @@ -230,8 +245,18 @@ def leave_component( if stack: return None - if get_memoization_strategy(comp) is MemoizationStrategy.SNAPSHOT: - return None + strategy = get_memoization_strategy(comp) + if strategy is MemoizationStrategy.SNAPSHOT: + from reflex_components_core.core.match import Match + + if not isinstance(comp, Match): + return None + + if not _should_memoize(comp): + return None + + wrapper = self._build_wrapper(comp, page_context, compile_context) + return None if wrapper is None else (wrapper, ()) if not _should_memoize(comp): return None diff --git a/tests/integration/test_var_operations.py b/tests/integration/test_var_operations.py index 1f752b71f2a..409a0838b2e 100644 --- a/tests/integration/test_var_operations.py +++ b/tests/integration/test_var_operations.py @@ -31,6 +31,7 @@ class VarOperationState(rx.State): int_var1: rx.Field[int] = rx.field(10) int_var2: rx.Field[int] = rx.field(5) int_var3: rx.Field[int] = rx.field(7) + match_selector: rx.Field[int] = rx.field(2) float_var1: rx.Field[float] = rx.field(10.5) float_var2: rx.Field[float] = rx.field(5.5) long_float: rx.Field[float] = rx.field(13212312312.1231231) @@ -660,6 +661,17 @@ def index(): ), id="foreach_in_match", ), + # stateful component branches in a match + rx.box( + rx.match( + VarOperationState.match_selector, + (0, rx.text(VarOperationState.int_var1 + 1)), + (1, rx.text(VarOperationState.int_var2 + 2)), + (2, rx.text(VarOperationState.str_var1.upper())), + rx.text(VarOperationState.list3.length()), + ), + id="stateful_match_three_cases", + ), # Literal range var in a foreach rx.box(rx.foreach(range(42, 80, 27), rx.text.span), id="range_in_foreach1"), rx.box(rx.foreach(range(42, 80, 3), rx.text.span), id="range_in_foreach2"), @@ -993,6 +1005,8 @@ def test_var_operations(driver, var_operations: AppHarness): ("obj_length", "3"), # foreach in a match ("foreach_in_match", "first\nsecond\nthird"), + # stateful branch components in a match + ("stateful_match_three_cases", "FIRST"), # literal range in a foreach ("range_in_foreach1", "4269"), ("range_in_foreach2", "42454851545760636669727578"), diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 12e5fa9e590..92f3400c66a 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1,6 +1,7 @@ # ruff: noqa: D101 import dataclasses +import re from collections.abc import Callable from types import SimpleNamespace from typing import Any, cast @@ -149,8 +150,6 @@ def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None: ("special_form", "body_marker"), [ ("foreach", "Array.prototype.map.call"), - ("cond", "flag_rx_state_?"), - ("match", "switch (JSON.stringify"), ], ) def test_special_form_memo_wrappers_render_structural_body( @@ -205,7 +204,8 @@ def special_child() -> Component: def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: - """The shared memoization strategy covers leaves and structural forms.""" + """The shared memoization strategy classifies structural render forms.""" + from reflex_components_core.core.match import Match from reflex_components_core.el.elements.forms import Form, Input foreach_parent = rx.box( @@ -232,8 +232,12 @@ def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: ) assert get_memoization_strategy(foreach_parent) is MemoizationStrategy.SNAPSHOT - assert get_memoization_strategy(cond_fragment) is MemoizationStrategy.SNAPSHOT - assert get_memoization_strategy(match_fragment) is MemoizationStrategy.SNAPSHOT + assert get_memoization_strategy(cond_fragment) is MemoizationStrategy.PASSTHROUGH + assert isinstance(match_fragment.children[0], Match) + assert ( + get_memoization_strategy(match_fragment.children[0]) + is MemoizationStrategy.SNAPSHOT + ) assert ( get_memoization_strategy(LeafComponent.create(Plain.create())) is MemoizationStrategy.SNAPSHOT @@ -555,3 +559,270 @@ def test_plugin_only_registered_once_in_default_page_plugins() -> None: ) memoize_index = plugins.index(memoize_plugins[0]) assert memoize_index > collector_index + + +def test_match_non_stateful_cond_allows_stateful_children_to_memoize() -> None: + """Match with a non-stateful condition must not suppress child memoization. + + Regression: Match was a MemoizationLeaf, causing it to push onto the + suppressor stack when its condition had no VarData. That blocked + independently-stateful children from being wrapped. After the fix Match + is a plain Component and its stateful children are memoized normally. + """ + + def page() -> Component: + comp = rx.match( + "static", # non-stateful condition + ("a", WithProp.create(label=STATE_VAR)), + WithProp.create(label=LiteralVar.create("default")), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected the stateful WithProp inside match cases to be memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + + +def test_cond_non_stateful_cond_allows_stateful_children_to_memoize() -> None: + """Cond with a non-stateful condition must not suppress child memoization. + + When the condition carries no VarData, Cond should not be extracted to its + own memo component. Its stateful children (comp1 / comp2) should still be + independently memoized. + """ + + def page() -> Component: + comp = rx.cond( + True, # non-stateful condition + WithProp.create(label=STATE_VAR), + WithProp.create(label=LiteralVar.create("false-branch")), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected the stateful WithProp inside cond branch to be memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + + +def test_cond_and_match_strategy_classification() -> None: + """Cond uses passthrough while Match uses snapshot strategy.""" + from reflex_components_core.core.match import Match + + cond_non_stateful = rx.cond( + True, + rx.text("yes"), + rx.text("no"), + ) + cond_stateful = rx.cond( + SpecialFormMemoState.flag, + rx.text("yes"), + rx.text("no"), + ) + match_non_stateful = rx.match( + "static", + ("a", rx.text("A")), + rx.text("default"), + ) + match_stateful = rx.match( + SpecialFormMemoState.value, + ("a", rx.text("A")), + rx.text("default"), + ) + + components = ( + cond_non_stateful, + cond_stateful, + ) + for comp in components: + assert isinstance(comp, Component) + assert get_memoization_strategy(comp) is MemoizationStrategy.PASSTHROUGH + + match_components = ( + match_non_stateful, + match_stateful, + ) + for comp in match_components: + assert isinstance(comp, Component) + assert isinstance(comp.children[0], Match) + assert ( + get_memoization_strategy(comp.children[0]) is MemoizationStrategy.SNAPSHOT + ) + + +def test_cond_stateful_var_branch_memoized_as_bare() -> None: + """rx.cond(True, STATE_VAR, "false") embeds a stateful ternary Var in a Bare. + + The ternary Var produced by the Var-returning cond path carries STATE_VAR's + VarData. When rendered inside rx.box it appears as a Bare child, which must + be extracted into its own memoized component. + """ + ctx, _page_ctx = _compile_single_page( + lambda: rx.box(rx.cond(True, STATE_VAR, "false")), + ) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected stateful cond ternary var to produce one memoized Bare, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + + +def test_cond_stateful_condition_memoizes_whole_cond_and_stateful_branch() -> None: + """Stateful Cond condition memoizes both Cond and stateful branch. + + Cond should recurse into branches so stateful branch components are wrapped + independently, while the Cond itself is also wrapped because its condition + var reads state. + """ + + def page() -> Component: + comp = rx.cond( + SpecialFormMemoState.flag, + WithProp.create(label=STATE_VAR), + WithProp.create(label=LiteralVar.create("false-branch")), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + + assert len(ctx.memoize_wrappers) == 2, ( + "Expected both Cond and its stateful branch component to be memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + wrapper_tags = tuple(ctx.memoize_wrappers) + assert any("cond" in tag.lower() for tag in wrapper_tags) + assert any("withprop" in tag.lower() for tag in wrapper_tags) + + +def test_match_stateful_condition_memoizes_whole_match_and_stateful_branch() -> None: + """Stateful Match condition memoizes both Match and stateful branch. + + Match should recurse into branches so stateful branch components are + memoized independently, while Match itself is memoized when its condition + var carries VarData. + """ + + def page() -> Component: + comp = rx.match( + SpecialFormMemoState.value, + ("a", WithProp.create(label=STATE_VAR)), + WithProp.create(label=LiteralVar.create("default")), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 2, ( + "Expected both Match and its stateful branch component to be memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + wrapper_tags = tuple(ctx.memoize_wrappers) + assert any("match" in tag.lower() for tag in wrapper_tags) + assert any("withprop" in tag.lower() for tag in wrapper_tags) + + +def test_cond_stateful_branch_component_renders_via_memoized_wrapper() -> None: + """Components inside Cond branches must render via their memo wrappers. + + Regression shape matching the Match case: when the walker memoizes a + branch component, Cond rendering must use the wrapped branch tag in page + output rather than the original unwrapped component tag. + """ + + def page() -> Component: + comp = rx.cond( + True, + WithProp.create(label=STATE_VAR), + WithProp.create(label=LiteralVar.create("false-branch")), + ) + assert isinstance(comp, Component) + return comp + + ctx, page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected stateful branch to produce one memo wrapper, got: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code or "" + assert f"jsx({wrapper_tag}," in output, ( + f"Memo wrapper {wrapper_tag!r} not found in page output.\n" + f"Output snippet: {output[:2000]}" + ) + + +def test_match_stateful_branch_component_renders_via_memoized_wrapper() -> None: + """Components inside Match branches must be rendered via their memo wrappers. + + Regression: Match._render() used self.match_cases / self.default directly + instead of self.children. The walker updates children when it memoizes a + branch component, but those updates were invisible to Match's render, so + the generated page JSX still referenced the original unwrapped component + tag rather than the memo wrapper. + """ + + def page() -> Component: + comp = rx.match( + "static", + ("a", WithProp.create(label=STATE_VAR)), + WithProp.create(label=LiteralVar.create("default")), + ) + assert isinstance(comp, Component) + return comp + + ctx, page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected stateful branch to produce one memo wrapper, got: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code or "" + assert f"jsx({wrapper_tag}," in output, ( + f"Memo wrapper {wrapper_tag!r} not found in page output.\n" + f"Output snippet: {output[:2000]}" + ) + + +def test_memoized_match_wrapper_has_no_page_side_case_children() -> None: + """Memoized Match wrapper should not receive case children from page output.""" + + def page() -> Component: + comp = rx.match( + SpecialFormMemoState.value, + ("a", rx.text("A")), + ("b", rx.text("B")), + rx.text("default"), + ) + assert isinstance(comp, Component) + return comp + + ctx, page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected stateful Match to produce one memo wrapper, got: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code or "" + + assert f"jsx({wrapper_tag}," in output, ( + f"Memo wrapper {wrapper_tag!r} not found in page output.\n" + f"Output snippet: {output[:2000]}" + ) + assert re.search( + rf"jsx\({re.escape(wrapper_tag)},\s*\{{\}},\s*(\[\s*\]|)\s*\)", + output, + ), ( + "Memoized Match wrapper should be called without rendered match-case " + "children in page output.\n" + f"Output snippet: {output[:2000]}" + ) + assert not re.search( + rf"jsx\({re.escape(wrapper_tag)},\s*\{{\}},\s*(\[\s*)?jsx\(", + output, + ), ( + "Memoized Match wrapper unexpectedly received rendered children in page " + "output.\n" + f"Output snippet: {output[:2000]}" + ) diff --git a/tests/units/components/core/test_cond.py b/tests/units/components/core/test_cond.py index bf10dc968bd..a3c45875417 100644 --- a/tests/units/components/core/test_cond.py +++ b/tests/units/components/core/test_cond.py @@ -123,6 +123,19 @@ def test_cond_no_else(): cond(True, "hello") # pyright: ignore [reportArgumentType] +def test_cond_render_missing_false_child_defaults_to_fragment() -> None: + """Test Cond renders missing false branch as empty Fragment.""" + comp = Cond._create( + cond=LiteralVar.create(True).bool(), + children=[Fragment.create(Text.create("hello"))], + ) + + rendered = comp.render() + + assert rendered["true_value"] == Fragment.create(Text.create("hello")).render() + assert rendered["false_value"] == Fragment.create().render() + + def test_cond_computed_var(): """Test if cond works with computed vars.""" From 47a4b88f4b10d47f82acfdfde66f7ec593e209fa Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 29 Apr 2026 01:38:16 +0500 Subject: [PATCH 44/59] perf: extend memo imports in place instead of remerging per memo --- reflex/compiler/compiler.py | 36 ++++++++++++++-- tests/units/experimental/test_memo.py | 62 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 53c681d89d3..39ff4931a9e 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -67,6 +67,36 @@ def _apply_common_imports( ) +def _extend_imports_in_place( + target: dict[str, list[ImportVar]], + import_dict: dict[str, Any] | tuple[tuple[str, Any], ...], +) -> None: + """Append imports to an existing parsed import dict. + + Args: + target: The import dictionary to update. + import_dict: The imports to append. + """ + for lib, fields in ( + import_dict if isinstance(import_dict, tuple) else import_dict.items() + ): + lib = ( + "$" + lib + if lib.startswith(("/utils/", "/components/", "/styles/", "/public/")) + else lib + ) + target_fields = target.setdefault(lib, []) + if isinstance(fields, (list, tuple, set)): + target_fields.extend( + ImportVar(field) if isinstance(field, str) else field + for field in fields + ) + else: + target_fields.append( + ImportVar(fields) if isinstance(fields, str) else fields + ) + + def _compile_document_root(root: Component) -> str: """Compile the document root. @@ -410,7 +440,7 @@ def _compile_memo_components( specifier = _memo_component_index_specifier(name) per_memo_files.append((path, code)) index_entries.append((name, specifier)) - aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) + _extend_imports_in_place(aggregate_imports, file_imports) for memo in experimental_memos: if isinstance(memo, ExperimentalMemoComponentDefinition): @@ -421,7 +451,7 @@ def _compile_memo_components( ) path = _memo_component_file_path(base_dir, name) per_memo_files.append((path, code)) - aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) + _extend_imports_in_place(aggregate_imports, file_imports) elif isinstance(memo, ExperimentalMemoFunctionDefinition): memo_render, memo_imports = utils.compile_experimental_function_memo(memo) name = memo_render["name"] @@ -430,7 +460,7 @@ def _compile_memo_components( ) path = _memo_component_file_path(base_dir, name) per_memo_files.append((path, code)) - aggregate_imports = utils.merge_imports(aggregate_imports, file_imports) + _extend_imports_in_place(aggregate_imports, file_imports) index_path = utils.get_components_path() index_code = templates.memo_index_template(index_entries) diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index f66d95a55a9..99b547551da 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -363,6 +363,68 @@ def my_card(children: rx.Var[rx.Component], *, title: rx.Var[str]) -> rx.Compone assert "export const MyCard = memo(" in code +def test_compile_memo_components_extends_imports_without_remerging( + monkeypatch: pytest.MonkeyPatch, +): + """Memo import aggregation should not repeatedly reprocess prior imports.""" + + def noop() -> None: + pass + + memos = tuple( + ExperimentalMemoComponentDefinition( + fn=noop, + python_name=f"memo_{idx}", + params=(), + export_name=f"Memo{idx}", + component=rx.fragment(), + passthrough_hole_child=None, + ) + for idx in range(5) + ) + + def fake_compile_experimental_component_memo( + definition: ExperimentalMemoComponentDefinition, + ) -> tuple[dict[str, str], dict[str, list[ImportVar]]]: + return {"name": definition.export_name}, {} + + def fake_compile_single_memo_component( + component_render: dict[str, str], + component_imports: dict[str, list[ImportVar]], + ) -> tuple[str, dict[str, list[ImportVar]]]: + return ( + f"export const {component_render['name']} = null", + {"shared-lib": [ImportVar(tag=component_render["name"])]}, + ) + + real_merge_imports = compiler.utils.merge_imports + + def reject_growing_merge(*imports): + if len(imports) == 2 and imports[0]: + msg = "aggregate imports should be extended, not remerged" + raise AssertionError(msg) + return real_merge_imports(*imports) + + monkeypatch.setattr( + compiler.utils, + "compile_experimental_component_memo", + fake_compile_experimental_component_memo, + ) + monkeypatch.setattr( + compiler, + "_compile_single_memo_component", + fake_compile_single_memo_component, + ) + monkeypatch.setattr(compiler.utils, "merge_imports", reject_growing_merge) + + files, aggregate_imports = compiler.compile_memo_components((), memos) + + assert len(files) == len(memos) + 1 + assert [import_var.tag for import_var in aggregate_imports["shared-lib"]] == [ + f"Memo{idx}" for idx in range(5) + ] + + def test_experimental_component_memo_get_imports(): """Experimental component memos should resolve imports during compilation.""" From 536a06c26c10b953365c836bc222cecb381e860c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 29 Apr 2026 01:43:12 +0500 Subject: [PATCH 45/59] pyi hash --- pyi_hashes.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index a0f8e87d6d3..5f6f8d3b6bb 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -20,7 +20,7 @@ "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/upload.pyi": "2dd6ba6e3a4d61fc1d79eb582a7cc548", "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", @@ -118,10 +118,6 @@ "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", - "packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.pyi": "542ccba14de2456c1a046697982e0147", - "packages/reflex-site-shared/src/reflex_site_shared/components/image_zoom.pyi": "3999125aeb7c1768495659b20b033f54", - "packages/reflex-site-shared/src/reflex_site_shared/components/marketing_button.pyi": "74b01fba2002f202c07c005195c67dc8", - "packages/reflex-site-shared/src/reflex_site_shared/components/marquee.pyi": "596f0121f0bd409500da85cdd842a35d", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e" From c5768604ca4f88c56efa1f166529c6b36167436f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 29 Apr 2026 01:54:39 +0500 Subject: [PATCH 46/59] perf: fold _deterministic_hash into a single incremental hasher The previous implementation rebuilt nested `str([...])` representations at every recursion level and md5-hashed each leaf, dominating the auto- memo tagging path. Replacing it with one type-tagged, length-prefixed walker over a single hasher drops hashing samples ~3x in flexgen compile (~8% wall on top of the prior merge_imports fix). --- .../src/reflex_base/components/component.py | 97 ++++++++++++------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 5604bcbe057..eedb337f751 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -7,6 +7,7 @@ import enum import functools import inspect +import operator import typing from abc import ABC, ABCMeta, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence @@ -524,14 +525,65 @@ def _hash_str(value: str) -> str: return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() -def _hash_sequence(value: Sequence) -> str: - return _hash_str(str([_deterministic_hash(v) for v in value])) +def _update_deterministic_hash(hasher: Any, value: object) -> None: + """Feed ``value`` into ``hasher`` using a self-delimiting, type-tagged encoding. + Each branch writes a distinct type tag plus length-prefixed payload, which + keeps the encoding injective without building intermediate strings — the + nested ``str([...])`` approach this replaces was the dominant cost of + ``_deterministic_hash`` (~4x speedup on synthetic, ~2x on real renders). -def _hash_dict(value: dict) -> str: - return _hash_sequence( - sorted([(k, _deterministic_hash(v)) for k, v in value.items()]) - ) + Args: + hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). + value: The value to fold into the hasher. + + Raises: + TypeError: If the value is not hashable. + """ + if value is None: + hasher.update(b"N") + elif isinstance(value, bool): + hasher.update(b"T" if value else b"F") + elif isinstance(value, (int, float, enum.Enum)): + hasher.update(b"n") + hasher.update(str(value).encode()) + elif isinstance(value, str): + encoded = value.encode() + hasher.update(b"s") + hasher.update(len(encoded).to_bytes(8, "little")) + hasher.update(encoded) + elif isinstance(value, dict): + items = sorted(value.items(), key=operator.itemgetter(0)) + hasher.update(b"d") + hasher.update(len(items).to_bytes(8, "little")) + for k, v in items: + _update_deterministic_hash(hasher, k) + _update_deterministic_hash(hasher, v) + elif isinstance(value, (tuple, list)): + hasher.update(b"l") + hasher.update(len(value).to_bytes(8, "little")) + for item in value: + _update_deterministic_hash(hasher, item) + elif isinstance(value, Var): + hasher.update(b"v") + _update_deterministic_hash(hasher, value._js_expr) + _update_deterministic_hash(hasher, value._get_all_var_data()) + elif dataclasses.is_dataclass(value): + fields = dataclasses.fields(value) + hasher.update(b"D") + hasher.update(len(fields).to_bytes(8, "little")) + for field in fields: + hasher.update(field.name.encode()) + _update_deterministic_hash(hasher, getattr(value, field.name)) + elif isinstance(value, BaseComponent): + hasher.update(b"C") + _update_deterministic_hash(hasher, value.render()) + else: + msg = ( + f"Cannot hash value `{value}` of type `{type(value).__name__}`. " + "Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported." + ) + raise TypeError(msg) def _deterministic_hash(value: object) -> str: @@ -546,36 +598,9 @@ def _deterministic_hash(value: object) -> str: Raises: TypeError: If the value is not hashable. """ - if value is None: - # Hash None as a special case. - return "None" - if isinstance(value, (int, float, enum.Enum)): - # Hash numbers and booleans directly. - return str(value) - if isinstance(value, str): - return _hash_str(value) - if isinstance(value, dict): - return _hash_dict(value) - if isinstance(value, (tuple, list)): - # Hash tuples by hashing each element. - return _hash_sequence(value) - if isinstance(value, Var): - return _hash_str( - str((value._js_expr, _deterministic_hash(value._get_all_var_data()))) - ) - if dataclasses.is_dataclass(value): - return _hash_dict({ - k.name: getattr(value, k.name) for k in dataclasses.fields(value) - }) - if isinstance(value, BaseComponent): - # If the value is a component, hash its rendered code. - return _hash_dict(value.render()) - - msg = ( - f"Cannot hash value `{value}` of type `{type(value).__name__}`. " - "Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported." - ) - raise TypeError(msg) + hasher = md5(usedforsecurity=False) + _update_deterministic_hash(hasher, value) + return hasher.hexdigest() @dataclasses.dataclass(kw_only=True, frozen=True, slots=True) From 09d274a9ffc8ec69bc051318af6624303f4e0601 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 28 Apr 2026 15:00:12 -0700 Subject: [PATCH 47/59] Use SNAPSHOT strategy for Cond as well Add functional test cases for Match and Cond in an integration test --- .../reflex_base/components/memoize_helpers.py | 8 +- reflex/compiler/plugins/memoize.py | 31 +++-- .../tests_playwright/test_cond_match.py | 112 ++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 97 +++++++++++++++ 4 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 tests/integration/tests_playwright/test_cond_match.py diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 1d5b91456d5..95d653da084 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -242,12 +242,12 @@ def get_memoization_strategy(component: Component) -> MemoizationStrategy: Returns: The strategy to use when generating a memo wrapper. """ + from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match - # Match compiles branch returns into switch/case-like code from explicit - # children, so it cannot use a single passthrough {children} hole in memo - # compilation. - if isinstance(component, Match): + # Cond and Match compile branch returns from explicit ordered children, so + # they cannot use a single passthrough {children} hole in memo compilation. + if isinstance(component, (Cond, Match)): return MemoizationStrategy.SNAPSHOT if ( diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 1743dc5329e..fea26588acc 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -111,7 +111,7 @@ def _should_memoize(component: Component) -> bool: if ( strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component) - and not isinstance(component, Match) + and not isinstance(component, (Cond, Match)) ): return True @@ -131,12 +131,13 @@ class MemoizeStatefulPlugin(Plugin): ``Foreach`` wrappers): wrapped in ``enter_component`` and returned with empty structural children. The walker skips descent, so hooks attached to the captured body are compiled into the memo body only. - - Passthrough wrappers (including ``Cond``) are wrapped in + - Passthrough wrappers are wrapped in ``leave_component`` after descendants have already compiled, so any inner memo wrappers flow into this wrapper's children. - - ``Match`` is classified as snapshot for memo rendering semantics, but is - handled specially to recurse during the page walk and wrap in - ``leave_component`` so case branches can still be memoized independently. + - ``Cond`` and ``Match`` are classified as snapshot for memo rendering + semantics, but are handled specially to recurse during the page walk and + wrap in ``leave_component`` so branch components can still be memoized + independently. Descendants of a snapshot boundary are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked @@ -188,11 +189,13 @@ def enter_component( strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None + from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match - if isinstance(comp, Match): - # Match needs snapshot memo body rendering but still must recurse so - # stateful case components can be memoized independently. + if isinstance(comp, (Cond, Match)): + # Cond and Match need snapshot memo body rendering but still must + # recurse so stateful branch components can be memoized + # independently. return None snapshot_boundary = is_snapshot_boundary(comp) @@ -245,11 +248,21 @@ def leave_component( if stack: return None + if len(children) != len(comp.children) or any( + compiled_child is not current_child + for compiled_child, current_child in zip( + children, comp.children, strict=True + ) + ): + comp = page_context.own(comp) + comp.children = list(children) + strategy = get_memoization_strategy(comp) if strategy is MemoizationStrategy.SNAPSHOT: + from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match - if not isinstance(comp, Match): + if not isinstance(comp, (Cond, Match)): return None if not _should_memoize(comp): diff --git a/tests/integration/tests_playwright/test_cond_match.py b/tests/integration/tests_playwright/test_cond_match.py new file mode 100644 index 00000000000..d382e8a519c --- /dev/null +++ b/tests/integration/tests_playwright/test_cond_match.py @@ -0,0 +1,112 @@ +"""Integration tests for stateful ``rx.cond`` and ``rx.match`` rendering.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def CondMatchApp(): + """App exercising conditional rendering across state transitions.""" + import reflex as rx + + class CondMatchState(rx.State): + val_a: str = "A" + val_b: str = "B" + + @rx.event + def select_a(self): + self.val_a = "A" + + @rx.event + def select_b(self): + self.val_a = "B" + + @rx.event + def select_c(self): + self.val_a = "C" + + def index(): + return rx.box( + rx.hstack( + rx.button("A", on_click=CondMatchState.select_a, id="select-a"), + rx.button("B", on_click=CondMatchState.select_b, id="select-b"), + rx.button("C", on_click=CondMatchState.select_c, id="select-c"), + ), + rx.text(CondMatchState.val_a, id="current-value"), + rx.box( + rx.cond( + CondMatchState.val_a == "A", + rx.text(CondMatchState.val_a, id="cond-true"), + rx.text(CondMatchState.val_b, id="cond-false"), + ), + id="cond-container", + ), + rx.box( + rx.match( + CondMatchState.val_a, + ("A", rx.text(CondMatchState.val_a + " is selected", id="match-a")), + ("B", rx.text(CondMatchState.val_b + " is selected", id="match-b")), + rx.text("No value selected", id="match-default"), + ), + id="match-container", + ), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def cond_match_app(tmp_path_factory: pytest.TempPathFactory) -> Generator[AppHarness, None, None]: + """Create a harness for the cond/match regression app. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + Running AppHarness for the test app. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("cond_match_app"), + app_source=CondMatchApp, + ) as harness: + yield harness + + +def test_cond_and_match_render_only_selected_branch( + cond_match_app: AppHarness, page: Page +): + """Cond and Match should render exactly one active branch per state value. + + Args: + cond_match_app: Running harness for the cond/match app. + page: Playwright page. + """ + assert cond_match_app.frontend_url is not None + page.goto(cond_match_app.frontend_url) + + expect(page.locator("#current-value")).to_have_text("A") + expect(page.locator("#cond-true")).to_have_text("A") + expect(page.locator("#cond-false")).to_have_count(0) + expect(page.locator("#match-a")).to_have_text("A is selected") + expect(page.locator("#match-b")).to_have_count(0) + expect(page.locator("#match-default")).to_have_count(0) + + page.click("#select-b") + expect(page.locator("#current-value")).to_have_text("B") + expect(page.locator("#cond-true")).to_have_count(0) + expect(page.locator("#cond-false")).to_have_text("B") + expect(page.locator("#match-a")).to_have_count(0) + expect(page.locator("#match-b")).to_have_text("B is selected") + expect(page.locator("#match-default")).to_have_count(0) + + page.click("#select-c") + expect(page.locator("#current-value")).to_have_text("C") + expect(page.locator("#cond-true")).to_have_count(0) + expect(page.locator("#cond-false")).to_have_text("B") + expect(page.locator("#match-a")).to_have_count(0) + expect(page.locator("#match-b")).to_have_count(0) + expect(page.locator("#match-default")).to_have_text("No value selected") \ No newline at end of file diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 92f3400c66a..0f9d39c55fe 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -755,6 +755,52 @@ def page() -> Component: ) +def test_cond_stateful_condition_renders_branch_logic_in_memo_body() -> None: + """Stateful Cond memo body must render both branches, not a children hole. + + Regression: component-valued ``rx.cond`` currently returns an outer + Fragment, so when the stateful condition is memoized the generated memo + body can collapse to ``condition ? children : ``. That drops the + false branch entirely and duplicates the true branch via the page-side + children payload. + """ + from reflex.compiler.compiler import compile_memo_components + + def page() -> Component: + comp = rx.cond( + SpecialFormMemoState.flag, + rx.text("yes"), + rx.text("no"), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected stateful Cond to produce one memo wrapper, got: {list(ctx.memoize_wrappers)}" + ) + + memo_files, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + memo_code = "\n".join(code for _, code in memo_files) + + assert '"yes"' in memo_code, ( + "Cond memo body should render the true branch.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert '"no"' in memo_code, ( + "Cond memo body should render the false branch.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "? children" not in memo_code, ( + "Cond memo body unexpectedly rendered a generic children hole instead " + "of branch-specific JSX.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + + def test_match_stateful_branch_component_renders_via_memoized_wrapper() -> None: """Components inside Match branches must be rendered via their memo wrappers. @@ -786,6 +832,57 @@ def page() -> Component: ) +def test_match_stateful_condition_uses_memoized_branch_wrapper_in_memo_body() -> None: + """Stateful Match memo body must call branch wrappers instead of inlining. + + Regression: when both the match condition and a branch are stateful, the + Match wrapper itself should be memoized and the branch should be memoized + separately. The generated Match memo body must call the branch wrapper in + the selected case rather than inlining the original branch component. + """ + from reflex.compiler.compiler import compile_memo_components + + def page() -> Component: + comp = rx.match( + SpecialFormMemoState.value, + ("a", WithProp.create(label=STATE_VAR)), + WithProp.create(label=LiteralVar.create("default")), + ) + assert isinstance(comp, Component) + return comp + + ctx, _page_ctx = _compile_single_page(page) + assert len(ctx.memoize_wrappers) == 2, ( + "Expected both Match and its stateful branch component to be memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + + match_wrapper_tag = next( + tag for tag in ctx.memoize_wrappers if "match" in tag.lower() + ) + branch_wrapper_tag = next( + tag for tag in ctx.memoize_wrappers if "withprop" in tag.lower() + ) + + memo_files, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + match_memo_code = next( + code for path, code in memo_files if path.endswith(f"/{match_wrapper_tag}.jsx") + ) + + assert f"jsx({branch_wrapper_tag}," in match_memo_code, ( + f"Expected Match memo body to reference branch memo wrapper {branch_wrapper_tag!r}.\n" + f"Memo code snippet: {match_memo_code[:2000]}" + ) + assert 'jsx(WithProp,{label:"value"},);' not in match_memo_code, ( + "Match memo body unexpectedly inlined the stateful branch component " + "instead of using its memo wrapper.\n" + f"Memo code snippet: {match_memo_code[:2000]}" + ) + + def test_memoized_match_wrapper_has_no_page_side_case_children() -> None: """Memoized Match wrapper should not receive case children from page output.""" From 07cadf38c1c57b5d646214788c8e1b1f537ff38b Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 28 Apr 2026 19:16:41 -0700 Subject: [PATCH 48/59] Make Cond and Match use PASSTHROUGH style memo strategy Avoid special cases for these components. Instead, map the special children prop to the conditional cases at render time. Even if the children prop is a frontend placeholder from an experimental memo component, it will still be indexed and render properly. --- .../reflex_base/components/memoize_helpers.py | 44 ++++-- .../src/reflex_components_core/core/cond.py | 14 ++ .../src/reflex_components_core/core/match.py | 25 ++- pyi_hashes.json | 4 + reflex/compiler/plugins/memoize.py | 32 +--- .../tests_playwright/test_cond_match.py | 6 +- tests/units/compiler/test_memoize_plugin.py | 142 +++++++++++------- 7 files changed, 169 insertions(+), 98 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 95d653da084..9b74e881753 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -23,12 +23,13 @@ from hashlib import md5 from typing import TYPE_CHECKING -from reflex_base.components.component import Component +from reflex_base.components.component import BaseComponent, 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 +from reflex_base.vars.sequence import ArrayVar if TYPE_CHECKING: from reflex_base.plugins.compiler import PageContext @@ -233,6 +234,38 @@ def _has_memoization_snapshot_child(component: Component) -> bool: ) +def passthrough_children_var( + children: list[BaseComponent], +) -> ArrayVar[list[BaseComponent]] | None: + """Return the placeholder ``children`` array Var if ``children`` is a memo hole. + + Auto-memo passthrough wrappers replace the wrapped component's children + with a single ``Bare(Var[Component](_js_expr="children"))`` placeholder + when compiling the memo body. Render-time consumers (notably ``Cond`` and + ``Match``) detect this and rewrite branches to index into the placeholder + array instead of capturing the original branch JSX in the memo body. The + returned Var is retyped to ``list[BaseComponent]`` so callers can index + it directly. + + Args: + children: The component's children list. + + Returns: + The placeholder Var (retyped as a Component list) for indexed access, + else ``None``. + """ + from reflex_components_core.base.bare import Bare + + if ( + len(children) == 1 + and isinstance(children[0], Bare) + and isinstance(children[0].contents, Var) + and children[0].contents._js_expr == "children" + ): + return children[0].contents.to(list[BaseComponent]) + return None + + def get_memoization_strategy(component: Component) -> MemoizationStrategy: """Get the render strategy for ``component`` if auto-memoization wraps it. @@ -242,14 +275,6 @@ def get_memoization_strategy(component: Component) -> MemoizationStrategy: Returns: The strategy to use when generating a memo wrapper. """ - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.match import Match - - # Cond and Match compile branch returns from explicit ordered children, so - # they cannot use a single passthrough {children} hole in memo compilation. - if isinstance(component, (Cond, Match)): - return MemoizationStrategy.SNAPSHOT - if ( is_snapshot_boundary(component) or _is_structural_memoization_child(component) @@ -266,4 +291,5 @@ def get_memoization_strategy(component: Component) -> MemoizationStrategy: "get_memoization_strategy", "get_memoized_event_triggers", "is_snapshot_boundary", + "passthrough_children_var", ] diff --git a/packages/reflex-components-core/src/reflex_components_core/core/cond.py b/packages/reflex-components-core/src/reflex_components_core/core/cond.py index bda854c5743..a35443cb17c 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/cond.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/cond.py @@ -5,6 +5,7 @@ from typing import Any, TypeVar, overload from reflex_base.components.component import BaseComponent, Component, field +from reflex_base.components.memoize_helpers import passthrough_children_var from reflex_base.components.tags import CondTag, Tag from reflex_base.constants import Dirs from reflex_base.style import LIGHT_COLOR_MODE, resolved_color_mode @@ -14,6 +15,7 @@ from reflex_base.vars.base import LiteralVar, Var from reflex_base.vars.number import ternary_operation +from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment _IS_TRUE_IMPORT: ImportDict = { @@ -29,9 +31,21 @@ class Cond(Component): def _get_cond_children(self) -> tuple[BaseComponent, BaseComponent]: """Get true and false branch components with safe defaults. + When rendering inside an auto-memo passthrough body, ``self.children`` + is collapsed to a single ``Bare`` holding the placeholder ``children`` + Var. The branches are reconstructed as indexed accesses + (``children[0]`` / ``children[1]``) so the page-side renders the real + branch JSX and the memo body just selects which one to mount. + Returns: A tuple containing true and false branch components. """ + children_var = passthrough_children_var(self.children) + if children_var is not None: + return ( + Bare.create(children_var[0]), + Bare.create(children_var[1]), + ) true_child = self.children[0] if self.children else Fragment.create() false_child = self.children[1] if len(self.children) > 1 else Fragment.create() return true_child, false_child diff --git a/packages/reflex-components-core/src/reflex_components_core/core/match.py b/packages/reflex-components-core/src/reflex_components_core/core/match.py index c29ed7c112f..9216cfb767b 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/match.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/match.py @@ -4,6 +4,7 @@ from typing import Any, cast from reflex_base.components.component import BaseComponent, Component, field +from reflex_base.components.memoize_helpers import passthrough_children_var from reflex_base.components.tags import Tag from reflex_base.components.tags.match_tag import MatchTag from reflex_base.style import Style @@ -14,6 +15,7 @@ from reflex_base.vars.base import LiteralVar, Var from reflex_components_core.base import Fragment +from reflex_components_core.base.bare import Bare class Match(Component): @@ -270,15 +272,22 @@ def _render(self) -> Tag: # self.children contains: [case_1_return, case_2_return, ..., default] # self.match_cases contains the conditions as Vars. num_cases = len(self.match_cases) - if len(self.children) != num_cases + 1: - msg = ( - f"Match children count mismatch: expected {num_cases + 1} " - f"(cases + default), got {len(self.children)}" - ) - raise ValueError(msg) + children_var = passthrough_children_var(self.children) + if children_var is not None: + # Auto-memo passthrough body: index into the placeholder array so + # branch JSX stays on the page side. + cases_returns = [Bare.create(children_var[i]) for i in range(num_cases)] + default_return = Bare.create(children_var[num_cases]) + else: + if len(self.children) != num_cases + 1: + msg = ( + f"Match children count mismatch: expected {num_cases + 1} " + f"(cases + default), got {len(self.children)}" + ) + raise ValueError(msg) - cases_returns = self.children[:num_cases] - default_return = self.children[num_cases] + cases_returns = self.children[:num_cases] + default_return = self.children[num_cases] return MatchTag( cond=str(self.cond), diff --git a/pyi_hashes.json b/pyi_hashes.json index 5f6f8d3b6bb..77559211a66 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,6 +118,10 @@ "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", + "packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.pyi": "542ccba14de2456c1a046697982e0147", + "packages/reflex-site-shared/src/reflex_site_shared/components/image_zoom.pyi": "3999125aeb7c1768495659b20b033f54", + "packages/reflex-site-shared/src/reflex_site_shared/components/marketing_button.pyi": "74b01fba2002f202c07c005195c67dc8", + "packages/reflex-site-shared/src/reflex_site_shared/components/marquee.pyi": "596f0121f0bd409500da85cdd842a35d", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e" diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index fea26588acc..98b8376d005 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -95,6 +95,8 @@ def _should_memoize(component: Component) -> bool: if isinstance(component, Bare) and component.contents._get_all_var_data(): # A stateful value will be wrapped in a separate component. return True + # Cond and Match render conditional branch JSX from their own props rather + # than from a tag, so they have no `tag` but still must be considered. if component.tag is None and not isinstance(component, (Cond, Match)): return False if component._memoization_mode.disposition == MemoizationDisposition.ALWAYS: @@ -108,11 +110,7 @@ def _should_memoize(component: Component) -> bool: # Snapshot-strategy non-boundaries (structural forms or their parents) # must memoize so the state-dependent render logic lands inside the memo # body instead of the page. - if ( - strategy is MemoizationStrategy.SNAPSHOT - and not is_snapshot_boundary(component) - and not isinstance(component, (Cond, Match)) - ): + if strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component): return True # Components with event triggers are always memoized (to wrap callbacks). @@ -134,10 +132,6 @@ class MemoizeStatefulPlugin(Plugin): - Passthrough wrappers are wrapped in ``leave_component`` after descendants have already compiled, so any inner memo wrappers flow into this wrapper's children. - - ``Cond`` and ``Match`` are classified as snapshot for memo rendering - semantics, but are handled specially to recurse during the page walk and - wrap in ``leave_component`` so branch components can still be memoized - independently. Descendants of a snapshot boundary are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked @@ -189,14 +183,6 @@ def enter_component( strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.match import Match - - if isinstance(comp, (Cond, Match)): - # Cond and Match need snapshot memo body rendering but still must - # recurse so stateful branch components can be memoized - # independently. - return None snapshot_boundary = is_snapshot_boundary(comp) if not _should_memoize(comp): @@ -259,17 +245,7 @@ def leave_component( strategy = get_memoization_strategy(comp) if strategy is MemoizationStrategy.SNAPSHOT: - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.match import Match - - if not isinstance(comp, (Cond, Match)): - return None - - if not _should_memoize(comp): - return None - - wrapper = self._build_wrapper(comp, page_context, compile_context) - return None if wrapper is None else (wrapper, ()) + return None if not _should_memoize(comp): return None diff --git a/tests/integration/tests_playwright/test_cond_match.py b/tests/integration/tests_playwright/test_cond_match.py index d382e8a519c..3791d792213 100644 --- a/tests/integration/tests_playwright/test_cond_match.py +++ b/tests/integration/tests_playwright/test_cond_match.py @@ -60,7 +60,9 @@ def index(): @pytest.fixture(scope="module") -def cond_match_app(tmp_path_factory: pytest.TempPathFactory) -> Generator[AppHarness, None, None]: +def cond_match_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: """Create a harness for the cond/match regression app. Args: @@ -109,4 +111,4 @@ def test_cond_and_match_render_only_selected_branch( expect(page.locator("#cond-false")).to_have_text("B") expect(page.locator("#match-a")).to_have_count(0) expect(page.locator("#match-b")).to_have_count(0) - expect(page.locator("#match-default")).to_have_text("No value selected") \ No newline at end of file + expect(page.locator("#match-default")).to_have_text("No value selected") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 0f9d39c55fe..86a9fcff29b 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -205,6 +205,7 @@ def special_child() -> Component: def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: """The shared memoization strategy classifies structural render forms.""" + from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match from reflex_components_core.el.elements.forms import Form, Input @@ -233,10 +234,17 @@ def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: assert get_memoization_strategy(foreach_parent) is MemoizationStrategy.SNAPSHOT assert get_memoization_strategy(cond_fragment) is MemoizationStrategy.PASSTHROUGH + # Cond and Match now use passthrough so branch JSX renders on the page side + # and the memo body just selects via children[i] indexing. + assert isinstance(cond_fragment.children[0], Cond) + assert ( + get_memoization_strategy(cond_fragment.children[0]) + is MemoizationStrategy.PASSTHROUGH + ) assert isinstance(match_fragment.children[0], Match) assert ( get_memoization_strategy(match_fragment.children[0]) - is MemoizationStrategy.SNAPSHOT + is MemoizationStrategy.PASSTHROUGH ) assert ( get_memoization_strategy(LeafComponent.create(Plain.create())) @@ -611,7 +619,8 @@ def page() -> Component: def test_cond_and_match_strategy_classification() -> None: - """Cond uses passthrough while Match uses snapshot strategy.""" + """Cond and Match both use passthrough; branches render on the page side.""" + from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match cond_non_stateful = rx.cond( @@ -635,23 +644,21 @@ def test_cond_and_match_strategy_classification() -> None: rx.text("default"), ) - components = ( - cond_non_stateful, - cond_stateful, - ) - for comp in components: + for comp in (cond_non_stateful, cond_stateful): assert isinstance(comp, Component) assert get_memoization_strategy(comp) is MemoizationStrategy.PASSTHROUGH + assert isinstance(comp.children[0], Cond) + assert ( + get_memoization_strategy(comp.children[0]) + is MemoizationStrategy.PASSTHROUGH + ) - match_components = ( - match_non_stateful, - match_stateful, - ) - for comp in match_components: + for comp in (match_non_stateful, match_stateful): assert isinstance(comp, Component) assert isinstance(comp.children[0], Match) assert ( - get_memoization_strategy(comp.children[0]) is MemoizationStrategy.SNAPSHOT + get_memoization_strategy(comp.children[0]) + is MemoizationStrategy.PASSTHROUGH ) @@ -756,13 +763,13 @@ def page() -> Component: def test_cond_stateful_condition_renders_branch_logic_in_memo_body() -> None: - """Stateful Cond memo body must render both branches, not a children hole. + """Stateful Cond memo body must select both branches via ``children`` indexing. - Regression: component-valued ``rx.cond`` currently returns an outer - Fragment, so when the stateful condition is memoized the generated memo - body can collapse to ``condition ? children : ``. That drops the - false branch entirely and duplicates the true branch via the page-side - children payload. + Cond is now a passthrough wrapper: branch JSX is rendered on the page side + and passed as the ``children`` array. The memo body's ternary must select + ``children[0]`` for the true branch and ``children[1]`` for the false + branch — neither branch should collapse to a generic ``? children`` hole + nor inline the original branch text into the memo body. """ from reflex.compiler.compiler import compile_memo_components @@ -775,7 +782,7 @@ def page() -> Component: assert isinstance(comp, Component) return comp - ctx, _page_ctx = _compile_single_page(page) + ctx, page_ctx = _compile_single_page(page) assert len(ctx.memoize_wrappers) == 1, ( f"Expected stateful Cond to produce one memo wrapper, got: {list(ctx.memoize_wrappers)}" ) @@ -786,20 +793,33 @@ def page() -> Component: ) memo_code = "\n".join(code for _, code in memo_files) - assert '"yes"' in memo_code, ( - "Cond memo body should render the true branch.\n" + assert "children?.at?.(0)" in memo_code, ( + "Cond memo body should select the true branch via children[0].\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "children?.at?.(1)" in memo_code, ( + "Cond memo body should select the false branch via children[1].\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert '"no"' in memo_code, ( - "Cond memo body should render the false branch.\n" + assert '"yes"' not in memo_code, ( + "Cond memo body unexpectedly inlined the true branch.\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert "? children" not in memo_code, ( - "Cond memo body unexpectedly rendered a generic children hole instead " - "of branch-specific JSX.\n" + assert '"no"' not in memo_code, ( + "Cond memo body unexpectedly inlined the false branch.\n" f"Memo code snippet: {memo_code[:2000]}" ) + page_output = page_ctx.output_code or "" + assert '"yes"' in page_output, ( + "Page output should render the true branch as a memo wrapper child.\n" + f"Page output snippet: {page_output[:2000]}" + ) + assert '"no"' in page_output, ( + "Page output should render the false branch as a memo wrapper child.\n" + f"Page output snippet: {page_output[:2000]}" + ) + def test_match_stateful_branch_component_renders_via_memoized_wrapper() -> None: """Components inside Match branches must be rendered via their memo wrappers. @@ -833,12 +853,13 @@ def page() -> Component: def test_match_stateful_condition_uses_memoized_branch_wrapper_in_memo_body() -> None: - """Stateful Match memo body must call branch wrappers instead of inlining. + """Stateful Match passes branch wrappers as page-side children. - Regression: when both the match condition and a branch are stateful, the - Match wrapper itself should be memoized and the branch should be memoized - separately. The generated Match memo body must call the branch wrapper in - the selected case rather than inlining the original branch component. + Match is now a passthrough wrapper: when both the match condition and a + branch are stateful, the Match wrapper itself is memoized and the branch + is memoized separately. The Match memo body selects via ``children[i]`` + indexing, and the page output renders the branch wrapper as a child of + the Match wrapper (rather than inlining the unwrapped branch component). """ from reflex.compiler.compiler import compile_memo_components @@ -851,7 +872,7 @@ def page() -> Component: assert isinstance(comp, Component) return comp - ctx, _page_ctx = _compile_single_page(page) + ctx, page_ctx = _compile_single_page(page) assert len(ctx.memoize_wrappers) == 2, ( "Expected both Match and its stateful branch component to be memoized, " f"got wrappers: {list(ctx.memoize_wrappers)}" @@ -872,19 +893,39 @@ def page() -> Component: code for path, code in memo_files if path.endswith(f"/{match_wrapper_tag}.jsx") ) - assert f"jsx({branch_wrapper_tag}," in match_memo_code, ( - f"Expected Match memo body to reference branch memo wrapper {branch_wrapper_tag!r}.\n" + assert "children?.at?.(0)" in match_memo_code, ( + "Match memo body should select case 0 via children indexing.\n" + f"Memo code snippet: {match_memo_code[:2000]}" + ) + assert "children?.at?.(1)" in match_memo_code, ( + "Match memo body should select the default via children indexing.\n" f"Memo code snippet: {match_memo_code[:2000]}" ) - assert 'jsx(WithProp,{label:"value"},);' not in match_memo_code, ( - "Match memo body unexpectedly inlined the stateful branch component " - "instead of using its memo wrapper.\n" + assert f"jsx({branch_wrapper_tag}," not in match_memo_code, ( + "Match memo body should not inline the branch wrapper; the branch " + "renders on the page side as a memo wrapper child.\n" f"Memo code snippet: {match_memo_code[:2000]}" ) + page_output = page_ctx.output_code or "" + assert f"jsx({match_wrapper_tag}," in page_output, ( + f"Page output should render the Match memo wrapper {match_wrapper_tag!r}.\n" + f"Output snippet: {page_output[:2000]}" + ) + assert f"jsx({branch_wrapper_tag}," in page_output, ( + f"Page output should render the branch memo wrapper {branch_wrapper_tag!r} " + "as a child of the Match wrapper.\n" + f"Output snippet: {page_output[:2000]}" + ) + -def test_memoized_match_wrapper_has_no_page_side_case_children() -> None: - """Memoized Match wrapper should not receive case children from page output.""" +def test_memoized_match_wrapper_receives_case_children_in_page_output() -> None: + """Passthrough Match wrapper receives all case children from the page output. + + With Match handled as a passthrough memo, the page renders each case's JSX + as a child of the Match wrapper. The memo body selects which child to mount + via ``children[i]`` indexing keyed on the (possibly stateful) condition. + """ def page() -> Component: comp = rx.match( @@ -907,19 +948,18 @@ def page() -> Component: f"Memo wrapper {wrapper_tag!r} not found in page output.\n" f"Output snippet: {output[:2000]}" ) + # Each case-return JSX, plus the default, must reach the wrapper as a child. + for case_text in ('"A"', '"B"', '"default"'): + assert case_text in output, ( + f"Expected case JSX {case_text} in page output as a Match wrapper child.\n" + f"Output snippet: {output[:2000]}" + ) + # Match wrapper must be called with three positional children (the cases plus + # default), not as an empty-children call. assert re.search( - rf"jsx\({re.escape(wrapper_tag)},\s*\{{\}},\s*(\[\s*\]|)\s*\)", - output, - ), ( - "Memoized Match wrapper should be called without rendered match-case " - "children in page output.\n" - f"Output snippet: {output[:2000]}" - ) - assert not re.search( - rf"jsx\({re.escape(wrapper_tag)},\s*\{{\}},\s*(\[\s*)?jsx\(", + rf"jsx\({re.escape(wrapper_tag)},\s*\{{\}},\s*jsx\(", output, ), ( - "Memoized Match wrapper unexpectedly received rendered children in page " - "output.\n" + "Match wrapper should receive case JSX as positional children in page output.\n" f"Output snippet: {output[:2000]}" ) From 129b186e760dc6bdc649ad328013a07b1285a08d Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 28 Apr 2026 21:18:28 -0700 Subject: [PATCH 49/59] Ensure global client_state setters have proper VarData --- reflex/experimental/client_state.py | 81 +++++++++++---------- tests/units/compiler/test_memoize_plugin.py | 62 ++++++++++++++++ 2 files changed, 103 insertions(+), 40 deletions(-) diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index 95fdbf06885..e24315b4734 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -22,28 +22,36 @@ } -def _client_state_ref(var_name: str) -> str: - """Get the ref path for a ClientStateVar. +def _client_state_ref(var_name: str) -> Var: + """Get the ref accessor Var for a ClientStateVar. Args: var_name: The name of the variable. Returns: - An accessor for ClientStateVar ref as a string. + A Var that accesses the ClientStateVar ref slot, carrying the + ``refs`` import from ``$/utils/state``. """ - return f"refs['_client_state_{var_name}']" + return Var( + _js_expr=f"refs['_client_state_{var_name}']", + _var_data=VarData(imports=_refs_import), + ) -def _client_state_ref_dict(var_name: str) -> str: - """Get the ref path for a ClientStateVar. +def _client_state_ref_dict(var_name: str) -> Var: + """Get the per-instance ref-dict accessor Var for a ClientStateVar. Args: var_name: The name of the variable. Returns: - An accessor for ClientStateVar ref as a string. + A Var that accesses the ClientStateVar's per-instance ref dict, + carrying the ``refs`` import from ``$/utils/state``. """ - return f"refs['_client_state_dict_{var_name}']" + return Var( + _js_expr=f"refs['_client_state_dict_{var_name}']", + _var_data=VarData(imports=_refs_import), + ) @dataclasses.dataclass( @@ -132,6 +140,10 @@ def create( } if global_ref: arg_name = get_unique_variable_name() + setter_ref = _client_state_ref(setter_name) + var_ref = _client_state_ref(var_name) + var_dict_ref = _client_state_ref_dict(var_name) + setter_dict_ref = _client_state_ref_dict(setter_name) func = ArgsFunctionOperationBuilder.create( args_names=(arg_name,), return_expr=Var("Array.prototype.forEach.call") @@ -140,13 +152,13 @@ def create( ( Var("Object.values") .to(FunctionVar) - .call(Var(_client_state_ref_dict(setter_name))) + .call(setter_dict_ref) .to(list) .to(list) ) - + Var.create([ - Var(f"(value) => {{ {_client_state_ref(var_name)} = value; }}") - ]).to(list), + + Var.create([Var(f"(value) => {{ {var_ref} = value; }}")]).to( + list + ), ArgsFunctionOperationBuilder.create( args_names=("setter",), return_expr=Var("setter").to(FunctionVar).call(Var(arg_name)), @@ -154,17 +166,16 @@ def create( ), ) - hooks[f"{_client_state_ref(setter_name)} = {func!s}"] = None - hooks[f"{_client_state_ref(var_name)} ??= {var_name!s}"] = None - hooks[f"{_client_state_ref_dict(var_name)} ??= {{}}"] = None - hooks[f"{_client_state_ref_dict(setter_name)} ??= {{}}"] = None - hooks[ - f"{_client_state_ref_dict(var_name)}[{id_name}] = {_client_state_ref(var_name)}" - ] = None - hooks[ - f"{_client_state_ref_dict(setter_name)}[{id_name}] = {setter_name}" - ] = None - imports.update(_refs_import) + hooks[f"{setter_ref!s} = {func!s}"] = setter_ref._get_all_var_data() + hooks[f"{var_ref!s} ??= {var_name!s}"] = var_ref._get_all_var_data() + hooks[f"{var_dict_ref!s} ??= {{}}"] = var_dict_ref._get_all_var_data() + hooks[f"{setter_dict_ref!s} ??= {{}}"] = setter_dict_ref._get_all_var_data() + hooks[f"{var_dict_ref!s}[{id_name}] = {var_ref!s}"] = VarData.merge( + var_dict_ref._get_all_var_data(), var_ref._get_all_var_data() + ) + hooks[f"{setter_dict_ref!s}[{id_name}] = {setter_name}"] = ( + setter_dict_ref._get_all_var_data() + ) return cls( _js_expr="null", _setter_name=setter_name, @@ -192,20 +203,12 @@ def value(self) -> Var: Returns: an accessor for the client state variable. """ - return ( - Var( - _js_expr=( - _client_state_ref_dict(self._getter_name) + f"[{self._id_name}]" - if self._global_ref - else self._getter_name - ), - _var_data=self._var_data, - ) - .to(self._var_type) - ._replace( - merge_var_data=VarData(imports=_refs_import if self._global_ref else {}) - ) + js_expr = ( + f"{_client_state_ref_dict(self._getter_name)}[{self._id_name}]" + if self._global_ref + else self._getter_name ) + return Var(_js_expr=js_expr, _var_data=self._var_data).to(self._var_type) def set_value(self, value: Any = NoValue) -> Var: """Set the value of the client state variable. @@ -220,12 +223,10 @@ def set_value(self, value: Any = NoValue) -> Var: Returns: A special EventChain Var which will set the value when triggered. """ - var_data = VarData(imports=_refs_import if self._global_ref else {}) - setter = ( - Var(_client_state_ref(self._setter_name)) + _client_state_ref(self._setter_name) if self._global_ref - else Var(self._setter_name, _var_data=var_data) + else Var(self._setter_name) ).to(FunctionVar) if value is not NoValue: diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 86a9fcff29b..263473a8fac 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -963,3 +963,65 @@ def page() -> Component: "Match wrapper should receive case JSX as positional children in page output.\n" f"Output snippet: {output[:2000]}" ) + + +def test_client_state_setter_in_call_function_event_imports_refs() -> None: + """A button whose ``on_click`` calls a global ``ClientStateVar`` setter + must memoize and the resulting memo body's imports must include ``refs`` + from ``$/utils/state``. + + Regression: ``ClientStateVar.set_value`` builds its setter as + ``refs['_client_state_']`` but the returned setter ``Var`` does not + carry the ``refs`` import. When the on_click event chain is compiled into + the memo body, the body references ``refs['_client_state_'](42)`` + with no matching ``import { refs } from "$/utils/state"`` — producing a + ``ReferenceError: refs is not defined`` at runtime. + """ + from reflex.compiler.compiler import compile_memo_components + from reflex.experimental.client_state import ClientStateVar + + counter = ClientStateVar.create("counter", default=0) + + def page() -> Component: + return rx.el.button( + "click", + on_click=rx.call_function(counter.set_value(42)), + ) + + ctx, _page_ctx = _compile_single_page(page) + + assert len(ctx.memoize_wrappers) == 1, ( + "Expected the button with a stateful on_click to be auto-memoized, " + f"got wrappers: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + + memo_files, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + memo_code = next( + code for path, code in memo_files if path.endswith(f"/{wrapper_tag}.jsx") + ) + + assert "refs['_client_state_setCounter'](42)" in memo_code, ( + "Expected the memo body to call the client-state setter via refs.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + + state_import_match = re.search( + r'^import\s*\{([^}]*)\}\s*from\s*"\$/utils/state"', + memo_code, + flags=re.MULTILINE, + ) + assert state_import_match is not None, ( + "Memo body must import from $/utils/state since the on_click handler " + "uses refs['_client_state_setCounter'].\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + imported_names = {name.strip() for name in state_import_match.group(1).split(",")} + assert "refs" in imported_names, ( + f"Memo body imports {imported_names!r} from $/utils/state but is missing " + "'refs' — the on_click handler references refs['_client_state_setCounter'].\n" + f"Memo code snippet: {memo_code[:2000]}" + ) From 55c3a100ee6f6b222756e2580157aef7b4e5120c Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 29 Apr 2026 00:10:08 -0700 Subject: [PATCH 50/59] Fix windows path issue in unit test --- tests/units/compiler/test_memoize_plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 263473a8fac..c4d4efd045a 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -3,6 +3,7 @@ import dataclasses import re from collections.abc import Callable +from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -890,7 +891,9 @@ def page() -> Component: experimental_memos=tuple(ctx.auto_memo_components.values()), ) match_memo_code = next( - code for path, code in memo_files if path.endswith(f"/{match_wrapper_tag}.jsx") + code + for path, code in memo_files + if Path(path).name == f"{match_wrapper_tag}.jsx" ) assert "children?.at?.(0)" in match_memo_code, ( @@ -1001,7 +1004,7 @@ def page() -> Component: experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = next( - code for path, code in memo_files if path.endswith(f"/{wrapper_tag}.jsx") + code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" ) assert "refs['_client_state_setCounter'](42)" in memo_code, ( From b9970d3a07bdf3e38dc43e9775a929bdf41347af Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 29 Apr 2026 00:30:35 -0700 Subject: [PATCH 51/59] Handle auto-memoized form children Copy the original component's `_get_all_refs` onto the memo wrapper so that handleSubmit can be properly generated, even when the form itself is moved to its own passthrough-memoized component that doesn't actually know its children. --- .../reflex_components_core/core/debounce.py | 11 ++- pyi_hashes.json | 2 +- reflex/compiler/plugins/memoize.py | 9 +- tests/units/compiler/test_memoize_plugin.py | 97 +++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/packages/reflex-components-core/src/reflex_components_core/core/debounce.py b/packages/reflex-components-core/src/reflex_components_core/core/debounce.py index 500cd1f96a6..b627e87eea5 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/debounce.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/debounce.py @@ -7,6 +7,7 @@ from reflex_base.components.component import Component, field from reflex_base.constants import EventTriggers from reflex_base.event import EventHandler, no_args_event_spec +from reflex_base.utils import format from reflex_base.vars import VarData from reflex_base.vars.base import Var @@ -106,7 +107,15 @@ def create(cls, *children: Component, **props: Any) -> Component: } props.setdefault("custom_attrs", {}).update(other_props, **child.custom_attrs) - # Carry base Component props. + # Carry base Component props. Drop any keys from child.style that + # collide with DebounceInput's own props — Reflex routes unknown child + # kwargs (e.g. ``debounce_timeout`` passed through ``rx.input``) into + # ``style``. + debounce_input_prop_names = { + format.to_camel_case(prop) for prop in cls.get_props() + } + for colliding_key in [k for k in child.style if k in debounce_input_prop_names]: + child.style.pop(colliding_key) props.setdefault("style", {}).update(child.style) if child.class_name is not None: props["class_name"] = f"{props.get('class_name', '')} {child.class_name}" diff --git a/pyi_hashes.json b/pyi_hashes.json index 77559211a66..9adbbf680be 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -16,7 +16,7 @@ "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/debounce.pyi": "58138b5f1d5901839729d839620ea4da", "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", diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 98b8376d005..7286d939107 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -286,7 +286,14 @@ def _build_wrapper( wrapper_factory, definition = create_passthrough_component_memo(tag, comp) compile_context.auto_memo_components[tag] = definition - return wrapper_factory() + wrapper = wrapper_factory() + # The wrapper has no structural children at the page level, but parents + # walking ``_get_all_refs`` (e.g. ``Form._get_form_refs`` collecting + # ref_ mappings into ``handleSubmit``) need to see refs from the + # wrapped subtree. Delegate ref collection to the original component + # so descendants inside the memo body remain reachable for ref lookup. + object.__setattr__(wrapper, "_get_all_refs", comp._get_all_refs) + return wrapper __all__ = ["MemoizeStatefulPlugin"] diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index c4d4efd045a..c6515af2414 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1028,3 +1028,100 @@ def page() -> Component: "'refs' — the on_click handler references refs['_client_state_setCounter'].\n" f"Memo code snippet: {memo_code[:2000]}" ) + + +def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: + """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. + + When ``rx.input`` is given both ``value`` and ``on_change`` it is wrapped by + ``DebounceInput`` so the underlying input is fully controlled without typing + jank. The wrapper carries DebounceInput-known props (``debounce_timeout``, + ``input_ref``, ``element``) and also forwards the inner TextField as the + ``element`` prop. The memo body produced by the auto-memoize plugin must: + + - Import ``DebounceInput`` from ``react-debounce-input`` and render it via + ``jsx(DebounceInput, ...)`` rather than rendering the inner TextField + directly. The whole point of the wrapping is to give react-debounce-input + ownership of the keystroke pipeline; if the memo emitted the inner + ``TextField.Root`` instead, controlled-input updates would race the + backend round-trip and drop characters. + - Pass ``debounceTimeout`` as a real DebounceInput prop, not via ``css``. + Reflex routes unknown TextFieldRoot kwargs (like ``debounce_timeout``) + into ``style`` at component construction; ``DebounceInput.create`` then + copies ``child.style`` into the wrapper, which can leak the timeout into + the rendered ``css`` block. The timeout belongs on the wrapper as a real + prop — leaking it to ``css`` makes it a no-op styling key while the real + debounce behavior depends on the prop alone. + - Wire ``element`` to ``RadixThemesTextField.Root`` so the underlying input + is the radix text field and not a bare ````. + """ + from reflex.compiler.compiler import compile_memo_components + + class DebounceState(BaseState): + value: str = "" + + @rx.event + def set_value(self, v: str) -> None: + self.value = v + + def page() -> Component: + return rx.input( + id="my_input", + value=DebounceState.value, + on_change=DebounceState.set_value, + debounce_timeout=250, + ) + + ctx, _page_ctx = _compile_single_page(page) + + assert len(ctx.memoize_wrappers) == 1, ( + "Expected the controlled rx.input to memoize as a single DebounceInput " + f"wrapper, got: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert "debounceinput" in wrapper_tag.lower(), ( + f"Memo wrapper tag should be derived from DebounceInput, got: {wrapper_tag!r}" + ) + + memo_files, _memo_imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + memo_code = next( + code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" + ) + + assert re.search( + r'^import\s+DebounceInput\s+from\s+"react-debounce-input"', + memo_code, + flags=re.MULTILINE, + ), ( + "Memo body must import DebounceInput from react-debounce-input.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "jsx(DebounceInput," in memo_code, ( + "Memo body must render via DebounceInput, not inline the inner TextField.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "debounceTimeout:250" in memo_code, ( + "Memo body must pass debounceTimeout as a DebounceInput prop.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "element:RadixThemesTextField.Root" in memo_code, ( + "Memo body must pass the radix TextField as DebounceInput's element prop.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + + css_block_match = re.search( + r"css:\(\{([^}]*)\}\)", + memo_code, + ) + css_contents = css_block_match.group(1) if css_block_match else "" + assert "debounceTimeout" not in css_contents, ( + "debounceTimeout leaked into the css block — it should only be a " + "DebounceInput prop. Reflex routes unknown TextFieldRoot kwargs into " + "style, and DebounceInput.create copies child.style verbatim, so the " + "timeout ends up duplicated as a no-op CSS key.\n" + f"css block: {css_contents!r}\n" + f"Memo code snippet: {memo_code[:2000]}" + ) From b867952ab4996bdb62eeb67ca4d1a34acc68512a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 29 Apr 2026 18:12:45 +0500 Subject: [PATCH 52/59] Memoize snapshot boundaries when subtree carries reactive data Snapshot boundaries (recursive=False) suppress descendants but were not themselves wrapped when their state lived in a child, so the state read leaked into the page module instead of the boundary's snapshot body. _should_memoize now walks descendants and wraps the boundary when any carry state-bearing or hooks-bearing Var data, event_triggers, or explicit user hooks. Refs from a static id prop are intentionally ignored so static-id-only elements stay unwrapped. The walk uses an id() seen-set so var_data.components reachable via multiple Vars (e.g. Foreach renderers) is visited once. Mark void/raw-text HTML elements (title, meta, style, textarea, script, br, hr, img, input, link, base, area, col, embed, source, track, wbr, plus SVG desc/title/script/style and base.RawLink/ScriptTag) as recursive=False so a stateful Bare child stays an inline text interpolation rather than rendering as jsx(El, {}, jsx(Bare_xxx, {})), which is invalid for void elements and stringifies to [object Object] for raw-text ones. Shared constant lives in constants.compiler. Make Component._get_vars(include_children=True) yield per child so the new subtree scan can short-circuit on first hit instead of materialising the whole subtree. --- .../src/reflex_base/components/component.py | 5 +- .../src/reflex_components_core/base/link.py | 5 + .../el/elements/forms.py | 5 + .../el/elements/inline.py | 5 + .../el/elements/media.py | 19 + .../el/elements/metadata.py | 11 + .../el/elements/scripts.py | 5 + .../el/elements/tables.py | 3 + .../el/elements/typography.py | 3 + pyi_hashes.json | 16 +- reflex/compiler/plugins/memoize.py | 61 +- .../test_memoize_edge_cases.py | 219 +++++ tests/units/compiler/test_memoize_plugin.py | 858 +++++++++++++++++- 13 files changed, 1190 insertions(+), 25 deletions(-) create mode 100644 tests/integration/tests_playwright/test_memoize_edge_cases.py diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index eedb337f751..325329b72cf 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1556,15 +1556,14 @@ def _get_vars( vars.append(var) if include_children: + yield from vars for child in self.children: if not isinstance(child, Component) or id(child) in ignore_ids: continue ignore_ids.add(id(child)) - child_vars = child._get_vars( + yield from child._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. diff --git a/packages/reflex-components-core/src/reflex_components_core/base/link.py b/packages/reflex-components-core/src/reflex_components_core/base/link.py index a3a7b89dfbf..47fc812d0cf 100644 --- a/packages/reflex-components-core/src/reflex_components_core/base/link.py +++ b/packages/reflex-components-core/src/reflex_components_core/base/link.py @@ -1,6 +1,7 @@ """Display the title of the current page.""" from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.base import BaseHTML @@ -11,6 +12,8 @@ class RawLink(BaseHTML): tag = "link" + _memoization_mode = MemoizationMode(recursive=False) + href: Var[str] = field(doc="The href.") rel: Var[str] = field(doc="The type of link.") @@ -21,6 +24,8 @@ class ScriptTag(BaseHTML): tag = "script" + _memoization_mode = MemoizationMode(recursive=False) + type_: Var[str] = field(doc="The type of script represented.") source: Var[str] = field(doc="The URI of an external script.") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py index 8a2422081a5..30374ccf09d 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py @@ -9,6 +9,7 @@ from reflex_base.components.component import field from reflex_base.components.tags.tag import Tag from reflex_base.constants import Dirs, EventTriggers +from reflex_base.constants.compiler import MemoizationMode from reflex_base.event import ( FORM_DATA, EventChain, @@ -310,6 +311,8 @@ class BaseInput(BaseHTML): tag = "input" + _memoization_mode = MemoizationMode(recursive=False) + accept: Var[str] = field(doc="Accepted types of files when the input is file type") alt: Var[str] = field(doc='Alternate text for input type="image"') @@ -657,6 +660,8 @@ class Textarea(BaseHTML): tag = "textarea" + _memoization_mode = MemoizationMode(recursive=False) + auto_complete: Var[str] = field( doc="Whether the form control should have autocomplete enabled" ) diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py index 6a3bf6a3d52..0adb1bbe31d 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py @@ -3,6 +3,7 @@ from typing import ClassVar, Literal from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from .base import BaseHTML @@ -85,6 +86,8 @@ class Br(BaseHTML): tag = "br" + _memoization_mode = MemoizationMode(recursive=False) + class Cite(BaseHTML): """Display the cite element.""" @@ -225,6 +228,8 @@ class Wbr(BaseHTML): tag = "wbr" + _memoization_mode = MemoizationMode(recursive=False) + a = A.create abbr = Abbr.create diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py index 940effdf52b..89c4c19b3a5 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py @@ -4,6 +4,7 @@ from reflex_base.components.component import Component, ComponentNamespace, field from reflex_base.constants.colors import Color +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.inline import ReferrerPolicy @@ -16,6 +17,8 @@ class Area(BaseHTML): tag = "area" + _memoization_mode = MemoizationMode(recursive=False) + alt: Var[str] = field(doc="Alternate text for the area, used for accessibility") coords: Var[str] = field(doc="Coordinates to define the shape of the area") @@ -83,6 +86,8 @@ class Img(BaseHTML): tag = "img" + _memoization_mode = MemoizationMode(recursive=False) + alt: Var[str] = field(doc="Alternative text for the image") cross_origin: Var[CrossOrigin] = field( @@ -140,6 +145,8 @@ class Track(BaseHTML): tag = "track" + _memoization_mode = MemoizationMode(recursive=False) + default: Var[bool] = field( doc="Indicates that the track should be enabled unless the user's preferences indicate otherwise" ) @@ -192,6 +199,8 @@ class Embed(BaseHTML): tag = "embed" + _memoization_mode = MemoizationMode(recursive=False) + src: Var[str] = field(doc="URL of the embedded content") type: Var[str] = field(doc="Media type of the embedded content") @@ -256,6 +265,8 @@ class Source(BaseHTML): tag = "source" + _memoization_mode = MemoizationMode(recursive=False) + media: Var[str] = field( doc="Media query indicating what device the linked resource is optimized for" ) @@ -875,12 +886,16 @@ class Desc(BaseHTML): tag = "desc" + _memoization_mode = MemoizationMode(recursive=False) + class Title(BaseHTML): """The SVG title component for titles.""" tag = "title" + _memoization_mode = MemoizationMode(recursive=False) + class Metadata(BaseHTML): """The SVG metadata component for metadata.""" @@ -893,6 +908,8 @@ class Script(BaseHTML): tag = "script" + _memoization_mode = MemoizationMode(recursive=False) + type: Var[str] = field(doc="MIME type of the script.") href: Var[str] = field(doc="URL of external script.") @@ -905,6 +922,8 @@ class SvgStyle(BaseHTML): tag = "style" + _memoization_mode = MemoizationMode(recursive=False) + type: Var[str] = field(doc="MIME type of the stylesheet.") media: Var[str] = field(doc="Media query for the stylesheet.") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py index a05a9c0d2bc..639518f208d 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py @@ -1,6 +1,7 @@ """Metadata classes.""" from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.element import Element @@ -15,6 +16,8 @@ class Base(BaseHTML): tag = "base" + _memoization_mode = MemoizationMode(recursive=False) + href: Var[str] target: Var[str] @@ -30,6 +33,8 @@ class Link(BaseHTML): tag = "link" + _memoization_mode = MemoizationMode(recursive=False) + cross_origin: Var[CrossOrigin] = field( doc="Specifies the CORS settings for the linked resource" ) @@ -66,6 +71,8 @@ class Meta(BaseHTML): # Inherits common attributes from BaseHTML tag = "meta" # The HTML tag for this element is + _memoization_mode = MemoizationMode(recursive=False) + char_set: Var[str] = field( doc="Specifies the character encoding for the HTML document" ) @@ -86,6 +93,8 @@ class Title(Element): tag = "title" + _memoization_mode = MemoizationMode(recursive=False) + # Had to be named with an underscore so it doesn't conflict with reflex.style Style in pyi class StyleEl(Element): @@ -97,6 +106,8 @@ class StyleEl(Element): suppress_hydration_warning: Var[bool] = Var.create(True) + _memoization_mode = MemoizationMode(recursive=False) + base = Base.create head = Head.create diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py index caa68031ec8..0170f8f6f05 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py @@ -1,6 +1,7 @@ """Scripts classes.""" from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.inline import ReferrerPolicy @@ -20,12 +21,16 @@ class Noscript(BaseHTML): tag = "noscript" + _memoization_mode = MemoizationMode(recursive=False) + class Script(BaseHTML): """Display the script element.""" tag = "script" + _memoization_mode = MemoizationMode(recursive=False) + async_: Var[bool] = field( doc="Indicates that the script should be executed asynchronously" ) diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py index 37e686751cc..b1915feac87 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py @@ -3,6 +3,7 @@ from typing import Literal from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from .base import BaseHTML @@ -19,6 +20,8 @@ class Col(BaseHTML): tag = "col" + _memoization_mode = MemoizationMode(recursive=False) + span: Var[int] = field(doc="Number of columns the col element spans") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py index 7ac0b737235..3c7f4129853 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py @@ -3,6 +3,7 @@ from typing import ClassVar, Literal from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from .base import BaseHTML @@ -57,6 +58,8 @@ class Hr(BaseHTML): tag = "hr" + _memoization_mode = MemoizationMode(recursive=False) + class Li(BaseHTML): """Display the li element.""" diff --git a/pyi_hashes.json b/pyi_hashes.json index 9adbbf680be..830825697f6 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -8,7 +8,7 @@ "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/link.pyi": "210db7b91e03f14dd3e0b459cf1eea8e", "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", @@ -27,15 +27,15 @@ "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/forms.pyi": "dc724d75a5d41ea9084f8836a3546f15", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "25a1c158b95d999b12dd443448a49567", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "37b3bd1435fde95c61846709022b2e45", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "2ea7fd8581eb30e3e18719d28f070725", "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/scripts.pyi": "ed0a544175a1d686b4f962919d42b17a", "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/el/elements/tables.pyi": "72b22d1b8b543e1450652445d9083b80", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "65f9fafd636741c0a2927b84e8c5180b", "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 7286d939107..c7700342526 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -71,12 +71,61 @@ def _compute_memo_tag(component: Component) -> str | None: ).capitalize() +def _subtree_has_reactive_data( + component: Component, _seen: set[int] | None = None +) -> bool: + """Whether ``component``'s subtree carries reactive signals worth memoizing. + + No-arg event handlers (``on_click=State.ping``) contribute hooks only via + ``event_triggers`` / ``_get_events_hooks``, not as a Var, so the per-Var + scan must be paired with an explicit ``event_triggers`` check. + + ``useRef`` from a static ``id`` prop is intentionally ignored — it lives + in ``_get_hooks_internal``, not in any Var, so static-id-only elements + don't surface here and aren't flagged. + + Args: + component: The component whose subtree to inspect. + _seen: Internal ``id()`` set to avoid revisiting the same component + via overlapping ``var_data.components`` and ``children`` paths. + + Returns: + True if the subtree carries event triggers, explicit hooks, or any + Var whose merged var_data has ``state`` or ``hooks``. + """ + if _seen is None: + _seen = set() + if id(component) in _seen: + return False + _seen.add(id(component)) + + if component.event_triggers: + return True + if component._get_hooks() is not None or component._get_added_hooks(): + return True + for var in component._get_vars(include_children=False): + var_data = var._get_all_var_data() + if var_data is None: + continue + if var_data.state or var_data.hooks: + return True + for comp in var_data.components: + if isinstance(comp, Component) and _subtree_has_reactive_data(comp, _seen): + return True + for child in component.children: + if isinstance(child, Component) and _subtree_has_reactive_data(child, _seen): + return True + return False + + 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. + Snapshot boundaries (``recursive=False``) suppress their descendants, + so a stateful subtree must trigger wrapping at the boundary itself — + otherwise the state read leaks into the page module. Other components + are evaluated from their own props/triggers; descendants are visited + independently by the walker. Args: component: The candidate component. @@ -107,12 +156,12 @@ def _should_memoize(component: Component) -> bool: if prop_var._get_all_var_data(): return True - # Snapshot-strategy non-boundaries (structural forms or their parents) - # must memoize so the state-dependent render logic lands inside the memo - # body instead of the page. if strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component): return True + if is_snapshot_boundary(component) and _subtree_has_reactive_data(component): + return True + # Components with event triggers are always memoized (to wrap callbacks). return bool(component.event_triggers) diff --git a/tests/integration/tests_playwright/test_memoize_edge_cases.py b/tests/integration/tests_playwright/test_memoize_edge_cases.py new file mode 100644 index 00000000000..abf2ea099c2 --- /dev/null +++ b/tests/integration/tests_playwright/test_memoize_edge_cases.py @@ -0,0 +1,219 @@ +"""Integration tests for auto-memoization edge cases. + +These exercise components whose memoization needs special care: + +- Snapshot boundaries (``recursive=False``) such as ``AccordionTrigger`` whose + state-dependent logic lives in a descendant. Without the snapshot wrapper + the cond's state read leaks into the page module and the trigger fails to + update on state transitions. +- HTML elements with constrained content models (````, ``<meta>``, + ``<style>``, ``<script>``). Independent memoization of a stateful ``Bare`` + child renders ``jsx("title", {}, jsx(Bare_xxx, {}))`` — React stringifies + the component child as ``[object Object]`` (or refuses to render at all + for void elements). Snapshot-wrapping keeps the Bare a text interpolation + inside the parent's body. + +Test design notes: +- ``document.title`` is not a reliable signal: React Router writes a + metadata title alongside any user-rendered ``<title>``. Tests inspect the + ``<title>`` element directly rather than ``document.title``. +- Style content is matched on a unique marker substring rather than common + selectors like ``body`` (which conflicts with Emotion/Sonner stylesheets). +- ``<textarea>``'s runtime value semantics belong to React (children are + initial-value-only); the no-Bare-component-child invariant is verified by + the unit tests instead. +""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def MemoEdgeCasesApp(): + """App exercising memoization edge cases.""" + import reflex as rx + + class MemoState(rx.State): + is_open: bool = False + title_marker: str = "memo-title-home" + css_marker: str = "memo-css-light" + counter: int = 0 + + @rx.event + def toggle_open(self): + self.is_open = not self.is_open + + @rx.event + def set_title_about(self): + self.title_marker = "memo-title-about" + + @rx.event + def set_css_dark(self): + self.css_marker = "memo-css-dark" + + @rx.event + def bump(self): + self.counter = self.counter + 1 + + def index(): + return rx.box( + rx.el.title(MemoState.title_marker), + rx.el.style("body { --memo-marker: " + MemoState.css_marker + "; }"), + rx.box( + rx.button("toggle", on_click=MemoState.toggle_open, id="toggle"), + rx.button("title", on_click=MemoState.set_title_about, id="set-title"), + rx.button("css", on_click=MemoState.set_css_dark, id="set-css"), + rx.button("bump", on_click=MemoState.bump, id="bump"), + ), + rx.accordion.root( + rx.accordion.item( + header=rx.accordion.header( + rx.accordion.trigger( + rx.cond( + MemoState.is_open, + rx.text("Hide", id="trigger-hide"), + rx.text("Show", id="trigger-show"), + ), + id="accordion-trigger", + ), + ), + content=rx.accordion.content(rx.text("body")), + value="item-1", + ), + ), + rx.text(MemoState.counter, id="counter"), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def memo_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Run the memoization edge-cases app under an AppHarness. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + The running harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("memo_edge_cases"), + app_source=MemoEdgeCasesApp, + ) as harness: + yield harness + + +def test_accordion_trigger_with_stateful_cond_updates( + memo_app: AppHarness, page: Page +) -> None: + """AccordionTrigger holding a stateful cond updates on state changes. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + + expect(page.locator("#trigger-show")).to_have_text("Show") + expect(page.locator("#trigger-hide")).to_have_count(0) + + page.click("#toggle") + expect(page.locator("#trigger-hide")).to_have_text("Hide") + expect(page.locator("#trigger-show")).to_have_count(0) + + # Bumping an unrelated counter must not desync the trigger render. + page.click("#bump") + expect(page.locator("#counter")).to_have_text("1") + expect(page.locator("#trigger-hide")).to_have_text("Hide") + + page.click("#toggle") + expect(page.locator("#trigger-show")).to_have_text("Show") + + +def _document_contains(page: Page, marker: str) -> bool: + """Whether any ``<title>`` or ``<style>`` element contains ``marker``. + + ``<title>``/``<style>`` content is metadata, not "visible" text, so the + Locator ``has_text`` filter skips them. Inspect text content via JS. + + Args: + page: Playwright page. + marker: Substring to look for in title/style element text content. + + Returns: + True if any title/style element's textContent contains the marker. + """ + return page.evaluate( + """(marker) => { + const els = document.querySelectorAll('title, style'); + return Array.from(els).some(el => (el.textContent || '').includes(marker)); + }""", + marker, + ) + + +def test_title_element_renders_stateful_var_as_text( + memo_app: AppHarness, page: Page +) -> None: + """``rx.el.title(state_var)`` writes the state value as the title's text. + + Verified by reading the title element's textContent directly. A passing + test means the state value lands as the title's text node, not a JSX + component child that would be stringified. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + page.wait_for_selector("#trigger-show") + + assert _document_contains(page, "memo-title-home") + assert not _document_contains(page, "memo-title-about") + + page.click("#set-title") + page.wait_for_function( + """() => Array.from(document.querySelectorAll('title')) + .some(el => (el.textContent || '').includes('memo-title-about'))""", + timeout=5000, + ) + assert _document_contains(page, "memo-title-about") + assert not _document_contains(page, "memo-title-home") + + +def test_style_element_renders_stateful_css_as_text( + memo_app: AppHarness, page: Page +) -> None: + """``rx.el.style(state_var)`` writes the state value as the stylesheet text. + + Uses a unique marker substring so the test does not collide with Emotion + or Sonner stylesheets that also live in the document. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + page.wait_for_selector("#trigger-show") + + assert _document_contains(page, "memo-css-light") + assert not _document_contains(page, "memo-css-dark") + + page.click("#set-css") + page.wait_for_function( + """() => Array.from(document.querySelectorAll('style')) + .some(el => (el.textContent || '').includes('memo-css-dark'))""", + timeout=5000, + ) + assert _document_contains(page, "memo-css-dark") + assert not _document_contains(page, "memo-css-light") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index c6515af2414..cc094385eb6 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -257,21 +257,25 @@ def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: def test_memoization_leaf_suppresses_descendant_wrapping() -> None: - """A MemoizationLeaf suppresses independent wrappers for its descendants. + """A snapshot boundary owns its subtree: descendants never wrap independently. - 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. + The boundary itself wraps once because its subtree is stateful (see + ``test_snapshot_boundary_wraps_subtree_once_when_descendant_is_stateful``). + The point of this test is the suppression invariant — only one wrapper + exists and it covers the boundary, never a separate wrapper for the + inner ``Plain(STATE_VAR)``. """ 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 + assert len(ctx.memoize_wrappers) == 1 + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert "leafcomponent" in wrapper_tag.lower(), ( + f"The single wrapper should cover the leaf, not its descendant. " + f"Got: {wrapper_tag!r}" + ) def test_generated_memo_component_is_not_itself_memoized() -> None: @@ -1125,3 +1129,841 @@ def page() -> Component: f"css block: {css_contents!r}\n" f"Memo code snippet: {memo_code[:2000]}" ) + + +def test_should_memoize_snapshot_boundary_with_stateful_descendant() -> None: + """A snapshot boundary memoizes when its subtree contains state-derived hooks. + + ``LeafComponent`` mirrors the Radix-primitive shape: ``recursive=False`` + set directly without inheriting from ``MemoizationLeaf``. + """ + boundary = LeafComponent.create(Plain.create(STATE_VAR)) + assert _should_memoize(boundary) + + +def test_snapshot_boundary_wraps_subtree_once_when_descendant_is_stateful() -> None: + """A snapshot boundary with a stateful descendant produces exactly one wrapper. + + The boundary owns its subtree; descendants must remain suppressed. End + result: one snapshot wrapper covering the boundary, no independent wrapper + for the stateful descendant. + """ + ctx, page_ctx = _compile_single_page( + lambda: LeafComponent.create(Plain.create(STATE_VAR)) + ) + assert len(ctx.memoize_wrappers) == 1, ( + "Expected exactly one snapshot wrapper covering the leaf and its " + f"stateful descendant. Got: {list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert "leafcomponent" in wrapper_tag.lower(), ( + f"Wrapper should be derived from LeafComponent, got: {wrapper_tag!r}" + ) + output = page_ctx.output_code or "" + assert f"jsx({wrapper_tag}," in output + + +def test_snapshot_boundary_with_static_subtree_is_not_wrapped() -> None: + """A snapshot boundary with no stateful descendant emits no wrapper. + + Sanity check: the new rule fires on subtree state, not on the boundary + flag alone. Static leaves stay on the page as before. + """ + ctx, _page_ctx = _compile_single_page( + lambda: LeafComponent.create(Plain.create(LiteralVar.create("static"))) + ) + assert len(ctx.memoize_wrappers) == 0, ( + f"Expected no wrapper for a fully static boundary; got: {list(ctx.memoize_wrappers)}" + ) + + +def test_snapshot_boundary_with_event_trigger_descendant_is_wrapped() -> None: + """A snapshot boundary with a stateful event-trigger descendant must wrap.""" + from reflex_base.event import EventChain + + event_var = Var(_js_expr="test_event")._replace( + _var_type=EventChain, + merge_var_data=VarData(state="TestState", hooks={"useTestState": None}), + ) + inner = Plain.create() + inner.event_triggers["on_click"] = event_var + boundary = LeafComponent.create(inner) + assert _should_memoize(boundary), ( + "Snapshot boundary with a stateful event-trigger descendant must memoize." + ) + + +def test_snapshot_boundary_with_no_arg_event_handler_descendant_is_wrapped() -> None: + """A boundary whose descendant has on_click without arg vars still wraps. + + No-arg handlers (``on_click=State.ping``) contribute to the page only + via the descendant's ``event_triggers`` and ``_get_events_hooks`` — the + per-Var subtree scan misses them. The reactive-data check must also + inspect ``event_triggers`` directly so the boundary wraps and the + callback's ``useCallback`` lands inside the snapshot body. + """ + inner = Plain.create() + inner.event_triggers["on_click"] = Var(_js_expr="evt") + boundary = LeafComponent.create(inner) + assert _should_memoize(boundary) + + +def test_title_with_stateful_var_child_does_not_wrap_bare_independently() -> None: + """``rx.el.title(state_var)`` must not produce a Bare component child. + + ``<title>`` is RCDATA — text content only. Wrapping the inner Bare as an + independent memo wrapper renders ``jsx("title", {}, jsx(Bare_xxx, {}))`` + which React refuses to interpolate as text. Marking ``Title`` as a + snapshot boundary keeps the Bare inside the title's snapshot, where it + renders as a text interpolation. + """ + from reflex_components_core.el.elements.metadata import Title + + title = Title.create(STATE_VAR) + ctx, page_ctx = _compile_single_page(lambda: title) + + assert len(ctx.memoize_wrappers) == 1, ( + "Expected exactly one snapshot wrapper for the title; got: " + f"{list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert wrapper_tag.lower().startswith("title_"), ( + f"Wrapper should be derived from Title, got: {wrapper_tag!r}" + ) + output = page_ctx.output_code or "" + assert "Bare_comp" not in output, ( + "Bare must not be independently memoized as a child of <title>. " + "It needs to remain a text interpolation inside the title's snapshot.\n" + f"Page output snippet: {output[:2000]}" + ) + + +def test_meta_with_stateful_var_child_does_not_wrap_bare_independently() -> None: + """``rx.el.meta(state_var)`` must not produce a Bare component child. + + ``<meta>`` is a void element — it forbids any children at all. Memoizing + the Bare independently produces ``jsx("meta", {}, jsx(Bare_xxx, {}))`` + which is invalid HTML. + """ + from reflex_components_core.el.elements.metadata import Meta + + meta = Meta.create(STATE_VAR) + ctx, page_ctx = _compile_single_page(lambda: meta) + + assert len(ctx.memoize_wrappers) == 1, ( + "Expected exactly one snapshot wrapper for the meta; got: " + f"{list(ctx.memoize_wrappers)}" + ) + output = page_ctx.output_code or "" + assert "Bare_comp" not in output, ( + "Bare must not be independently memoized as a child of <meta>.\n" + f"Page output snippet: {output[:2000]}" + ) + + +def _text_only_classes() -> list: + from reflex_components_core.el.elements.forms import Textarea + from reflex_components_core.el.elements.metadata import StyleEl + from reflex_components_core.el.elements.scripts import Script + + return [ + pytest.param(StyleEl, id="style"), + pytest.param(Textarea, id="textarea"), + pytest.param(Script, id="script"), + ] + + +@pytest.mark.parametrize("cls", _text_only_classes()) +def test_text_only_element_with_stateful_var_child_does_not_wrap_bare( + cls: type[Component], +) -> None: + """Text-only HTML elements must not wrap stateful Bare children as components. + + ``<style>``/``<textarea>``/``<script>`` all have raw-text content models. + A JSX component child renders as a stringified ``[object Object]`` — the + text interpolation needs to land inside the element's snapshot body. + """ + component = cls.create(STATE_VAR) + ctx, page_ctx = _compile_single_page(lambda: component) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected exactly one snapshot wrapper; got: {list(ctx.memoize_wrappers)}" + ) + output = page_ctx.output_code or "" + assert "Bare_comp" not in output, ( + "Bare must not be independently memoized as a child of a raw-text " + f"element.\nPage output snippet: {output[:2000]}" + ) + + +def test_accordion_trigger_with_stateful_cond_is_memoized() -> None: + """AccordionTrigger holding a stateful cond wraps as a single snapshot. + + AccordionTrigger sets ``recursive=False`` without inheriting from + ``MemoizationLeaf``; the boundary itself must memoize so the cond's + state read lands inside the snapshot rather than the page module. + """ + from reflex_components_radix.primitives.accordion import AccordionTrigger + + trigger = AccordionTrigger.create( + rx.cond( + SpecialFormMemoState.flag, + rx.text("Hide"), + rx.text("Show"), + ) + ) + ctx, page_ctx = _compile_single_page(lambda: trigger) + + wrapper_tags = list(ctx.memoize_wrappers) + trigger_wrappers = [t for t in wrapper_tags if "trigger" in t.lower()] + assert trigger_wrappers, ( + "AccordionTrigger with a stateful cond must produce its own snapshot " + f"wrapper. Got wrappers: {wrapper_tags}" + ) + + output = page_ctx.output_code or "" + assert "useContext(StateContexts" not in output, ( + "State read leaked into the page module — the trigger's stateful cond " + "should be captured inside the snapshot wrapper instead.\n" + f"Page output snippet: {output[:2000]}" + ) + + +def _restricted_content_components() -> list: + """Build factories for all components flagged ``recursive=False`` in this PR. + + Returns: + Parameterized factories yielding ``Component`` instances when called + with ``STATE_VAR`` as a child. + """ + from reflex_components_core.base.link import RawLink, ScriptTag + from reflex_components_core.el.elements.forms import BaseInput, Textarea + from reflex_components_core.el.elements.inline import Br, Wbr + from reflex_components_core.el.elements.media import ( + Area, + Desc, + Embed, + Img, + Source, + SvgStyle, + Track, + ) + from reflex_components_core.el.elements.media import Script as SvgScript + from reflex_components_core.el.elements.media import Title as SvgTitle + from reflex_components_core.el.elements.metadata import ( + Base, + Link, + Meta, + StyleEl, + Title, + ) + from reflex_components_core.el.elements.scripts import Noscript, Script + from reflex_components_core.el.elements.tables import Col + from reflex_components_core.el.elements.typography import Hr + + cases: list[tuple[str, type]] = [ + # text-only (RCDATA / raw text) + ("title", Title), + ("style", StyleEl), + ("textarea", Textarea), + ("script", Script), + ("noscript", Noscript), + ("script_tag", ScriptTag), + # void HTML elements + ("meta", Meta), + ("base", Base), + ("link", Link), + ("raw_link", RawLink), + ("input", BaseInput), + ("br", Br), + ("wbr", Wbr), + ("col", Col), + ("hr", Hr), + ("area", Area), + ("img", Img), + ("track", Track), + ("embed", Embed), + ("source", Source), + # SVG raw-text equivalents + ("svg_desc", Desc), + ("svg_title", SvgTitle), + ("svg_script", SvgScript), + ("svg_style", SvgStyle), + ] + return [pytest.param(cls, id=name) for name, cls in cases] + + +@pytest.mark.parametrize("component_cls", _restricted_content_components()) +def test_restricted_content_element_isolates_stateful_bare_via_snapshot( + component_cls: type[Component], +) -> None: + """Restricted-content elements snapshot-wrap and never expose a Bare child. + + Asserts both the classification (the element opts into SNAPSHOT) and the + invariant (a stateful Bare child stays inside the snapshot rather than + being independently wrapped as a JSX component child of an element whose + content model rejects components). + """ + from reflex_base.components.memoize_helpers import is_snapshot_boundary + + instance = component_cls.create() + assert is_snapshot_boundary(instance), ( + f"{component_cls.__qualname__} should be classified as a snapshot boundary." + ) + assert get_memoization_strategy(instance) is MemoizationStrategy.SNAPSHOT, ( + f"{component_cls.__qualname__} should use SNAPSHOT strategy" + ) + + ctx, page_ctx = _compile_single_page(lambda: component_cls.create(STATE_VAR)) + output = page_ctx.output_code or "" + + assert "Bare_comp" not in output, ( + f"Stateful Bare child of <{getattr(component_cls, 'tag', '?')}> " + f"({component_cls.__qualname__}) was independently wrapped. The " + "element's snapshot must capture the Bare inline.\n" + f"Page output snippet: {output[:2000]}" + ) + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected exactly one snapshot wrapper for {component_cls.__qualname__}, " + f"got: {list(ctx.memoize_wrappers)}" + ) + + +def _compile_memo_module_text(ctx: CompileContext) -> str: + """Compile the auto-memo definitions and return the concatenated JSX text. + + Args: + ctx: The compile context produced by ``_compile_single_page``. + + Returns: + The full memo module source code joined by newlines. + """ + from reflex.compiler.compiler import compile_memo_components + + memo_files, _imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + return "\n".join(code for _, code in memo_files) + + +def test_title_memo_body_renders_text_interpolation_not_bare_component() -> None: + """The title's memo body must interpolate the state Var as text. + + Concretely: the body should reference the stateful identifier (e.g. + ``"value"``-bearing context wiring) inside a ``jsx("title", {}, …)`` call, + and must not contain ``jsx(Bare_comp_…``. Combined with the page-side + "no Bare_comp" assertion, this proves the snapshot keeps the Bare inline. + """ + from reflex_components_core.el.elements.metadata import Title + + ctx, page_ctx = _compile_single_page(lambda: Title.create(STATE_VAR)) + memo_code = _compile_memo_module_text(ctx) + + assert 'jsx("title"' in memo_code, ( + f'Title snapshot body should contain a literal ``jsx("title", …)`` ' + f"call. Memo code:\n{memo_code[:2000]}" + ) + assert "Bare_comp" not in memo_code, ( + "Title memo body should not nest an independently-memoized Bare " + f"component.\nMemo code:\n{memo_code[:2000]}" + ) + + page_output = page_ctx.output_code or "" + assert "Bare_comp" not in page_output + + +def test_meta_memo_body_renders_void_element_inline() -> None: + """Meta's snapshot body should call ``jsx("meta", …)`` with no nested Bare.""" + from reflex_components_core.el.elements.metadata import Meta + + ctx, _page_ctx = _compile_single_page(lambda: Meta.create(STATE_VAR)) + memo_code = _compile_memo_module_text(ctx) + + assert 'jsx("meta"' in memo_code + assert "Bare_comp" not in memo_code + + +def test_snapshot_boundary_memo_body_subscribes_state_in_body_not_page() -> None: + """State subscription wiring lives in the memo body, not in the page module. + + The whole point of memoization is to isolate state reads from the page. + This asserts that ``useContext(StateContexts…)`` (state subscription) + appears in the memo module and NOT in the page output, confirming the + state read landed inside the snapshot wrapper. + """ + from reflex_components_radix.primitives.accordion import AccordionTrigger + + trigger = AccordionTrigger.create( + rx.cond( + SpecialFormMemoState.flag, + rx.text("Hide"), + rx.text("Show"), + ) + ) + ctx, page_ctx = _compile_single_page(lambda: trigger) + memo_code = _compile_memo_module_text(ctx) + + assert "useContext(StateContexts" in memo_code, ( + "Snapshot wrapper should subscribe to state inside the memo body." + ) + page_output = page_ctx.output_code or "" + assert "useContext(StateContexts" not in page_output, ( + "State subscription should NOT appear in the page module — it must be " + "isolated inside the snapshot wrapper.\n" + f"Page output:\n{page_output[:2000]}" + ) + + +def test_nested_snapshot_boundaries_produce_one_outer_wrapper() -> None: + """A snapshot boundary inside another snapshot boundary produces ONE wrapper. + + The outer boundary's suppressor stack must absorb the inner boundary into + its own snapshot. Two nested wrappers would both duplicate the inner + component AND defeat the boundary's "I own my subtree" contract. + """ + inner = LeafComponent.create(Plain.create(STATE_VAR)) + outer = LeafComponent.create(inner) + ctx, _page_ctx = _compile_single_page(lambda: outer) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Nested snapshot boundaries must collapse to one outer wrapper; got " + f"{list(ctx.memoize_wrappers)}" + ) + + +def test_memoization_leaf_subclass_and_raw_recursive_false_behave_identically() -> None: + """Both ways to opt into recursive=False produce one snapshot wrapper. + + ``MemoizationLeaf`` subclasses and components that simply set + ``_memoization_mode = MemoizationMode(recursive=False)`` are handled + equivalently by the compiler. + """ + from reflex_base.components.component import MemoizationLeaf + + class LeafSubclass(MemoizationLeaf): + tag = "LeafSubclass" + library = "leaf-subclass-lib" + + leaf_subclass = LeafSubclass.create(Plain.create(STATE_VAR)) + raw_leaf = LeafComponent.create(Plain.create(STATE_VAR)) + + ctx_a, _ = _compile_single_page(lambda: leaf_subclass) + ctx_b, _ = _compile_single_page(lambda: raw_leaf) + + assert len(ctx_a.memoize_wrappers) == 1 + assert len(ctx_b.memoize_wrappers) == 1 + + +def test_snapshot_boundary_with_multiple_stateful_descendants_emits_one_wrapper() -> ( + None +): + """One boundary + many stateful descendants = one wrapper (not one per descendant). + + Without this invariant, a Radix primitive wrapping several stateful + children would balloon the page with one wrapper per child even though + the boundary already owns the subtree. + """ + boundary = LeafComponent.create( + Plain.create(STATE_VAR), + Plain.create(STATE_VAR), + WithProp.create(label=STATE_VAR), + ) + ctx, _page_ctx = _compile_single_page(lambda: boundary) + assert len(ctx.memoize_wrappers) == 1, ( + f"Multiple stateful descendants must share the boundary's wrapper; got " + f"{list(ctx.memoize_wrappers)}" + ) + + +def test_repeated_snapshot_boundary_subtrees_dedupe_to_one_definition() -> None: + """Two identical boundary subtrees collapse to one memo definition. + + Memo definitions are keyed on the rendered subtree shape, so two + identical boundaries should share a wrapper tag (even though they appear + twice on the page). + """ + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create( + LeafComponent.create(Plain.create(STATE_VAR)), + LeafComponent.create(Plain.create(STATE_VAR)), + ) + ) + assert len(ctx.memoize_wrappers) == 1, ( + f"Identical boundary subtrees should share one wrapper; got " + f"{list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert (page_ctx.output_code or "").count(f"jsx({wrapper_tag},") == 2 + + +def test_passthrough_wrapper_inside_snapshot_boundary_is_suppressed() -> None: + """Passthrough-eligible descendants of a snapshot boundary are suppressed. + + Without suppression, the descendant would emit its own wrapper that the + boundary's snapshot then references — which works visually but defeats + the "boundary owns the subtree" contract and pollutes the wrapper list. + """ + # Plain.create(STATE_VAR) on its own is a passthrough memo candidate. + boundary = LeafComponent.create(Plain.create(STATE_VAR)) + ctx, _page_ctx = _compile_single_page(lambda: boundary) + assert len(ctx.memoize_wrappers) == 1 + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert "leafcomponent" in wrapper_tag.lower(), ( + "The single wrapper should be the boundary's, not a separate " + f"passthrough wrapper for the descendant. Got: {wrapper_tag!r}" + ) + + +def test_snapshot_boundary_with_stateful_prop_and_descendant_emits_one_wrapper() -> ( + None +): + """A boundary with both stateful props and stateful descendants memoizes once.""" + from reflex_components_core.el.elements.metadata import Title + + title = Title.create( + STATE_VAR, # stateful child Bare + class_name=STATE_VAR.to(str), # stateful prop + ) + ctx, _page_ctx = _compile_single_page(lambda: title) + assert len(ctx.memoize_wrappers) == 1 + + +def test_disposition_never_overrides_snapshot_boundary_subtree_check() -> None: + """``MemoizationDisposition.NEVER`` wins even with a stateful subtree. + + Snapshot boundaries that explicitly opt out via NEVER must stay + unwrapped — useful for components that do their own memoization + elsewhere or shouldn't be memoized for correctness reasons. + """ + boundary = LeafComponent.create(Plain.create(STATE_VAR)) + object.__setattr__( + boundary, + "_memoization_mode", + dataclasses.replace( + boundary._memoization_mode, + disposition=MemoizationDisposition.NEVER, + ), + ) + assert not _should_memoize(boundary) + + +def test_static_subtree_inside_passthrough_no_memo_at_all() -> None: + """Sanity: a fully static page produces no memo wrappers. + + Guards against a regression where the new branch incorrectly fires for + components without state hooks. + """ + ctx, _page_ctx = _compile_single_page( + lambda: rx.box(rx.text("static"), rx.text("also static")) + ) + assert len(ctx.memoize_wrappers) == 0, ( + f"No state, no wrappers expected. Got: {list(ctx.memoize_wrappers)}" + ) + + +def test_void_element_with_only_stateful_prop_memoizes_via_snapshot() -> None: + """A void element with only a stateful prop still snapshot-wraps cleanly. + + Verifies that even without children, stateful props on void elements go + through the boundary's snapshot wrapper rather than degrading to a + passthrough that re-reads state on the page. + """ + from reflex_components_core.el.elements.media import Img + + img = Img.create(src=STATE_VAR.to(str)) + ctx, page_ctx = _compile_single_page(lambda: img) + assert len(ctx.memoize_wrappers) == 1 + assert "useContext(StateContexts" not in (page_ctx.output_code or "") + + +def _static_id_only_factories() -> list: + from reflex_components_core.el.elements.forms import BaseInput + from reflex_components_core.el.elements.inline import Br + from reflex_components_core.el.elements.media import Img + from reflex_components_core.el.elements.metadata import Meta, Title + + return [ + pytest.param(lambda: Title.create("hello", id="t"), id="title_with_id"), + pytest.param(lambda: Img.create(src="/x.png", id="logo"), id="img_with_id"), + pytest.param(lambda: Br.create(id="br"), id="br_with_id"), + pytest.param(lambda: BaseInput.create(id="i"), id="input_with_id"), + pytest.param( + lambda: Meta.create(name="description", id="m"), id="meta_with_id" + ), + ] + + +@pytest.mark.parametrize("factory", _static_id_only_factories()) +def test_static_restricted_element_with_id_only_does_not_memoize( + factory: Callable[[], Component], +) -> None: + """Restricted-content elements with only an ``id`` ref and no state stay unwrapped. + + The subtree scan filters on state/hooks-bearing var_data so ``useRef`` + lines from ``id`` props alone do not trigger wrapping. + """ + component = factory() + ctx, _page_ctx = _compile_single_page(lambda: component) + assert len(ctx.memoize_wrappers) == 0, ( + f"Static restricted element with only an id ref should not memoize. " + f"Got wrappers: {list(ctx.memoize_wrappers)}" + ) + + +def test_static_restricted_element_no_id_no_children_does_not_memoize() -> None: + """Sanity: a fully static restricted element with no props/children stays unwrapped.""" + from reflex_components_core.el.elements.metadata import Title + + ctx, _page_ctx = _compile_single_page(lambda: Title.create("static-string")) + assert len(ctx.memoize_wrappers) == 0, ( + f"Static title should not memoize. Got: {list(ctx.memoize_wrappers)}" + ) + + +def test_client_state_value_inside_snapshot_boundary_is_memoized() -> None: + """Client-state Vars are reactive and must trigger boundary memoization. + + A ``client_state`` Var contributes its ``useState``/``useId`` hooks via + ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var + walk must catch the hooks-only case so client-state-driven content + inside a snapshot boundary lands in the memo body. + """ + from reflex_components_core.el.elements.metadata import Title + + from reflex.experimental.client_state import ClientStateVar + + cs_var = ClientStateVar.create("titletest", default="hi", global_ref=False) + title = Title.create(cs_var.value) + ctx, page_ctx = _compile_single_page(lambda: title) + assert len(ctx.memoize_wrappers) == 1, ( + "Client-state-driven title content must memoize. Got: " + f"{list(ctx.memoize_wrappers)}" + ) + page_output = page_ctx.output_code or "" + assert "useState" not in page_output, ( + "Client-state hooks should be inside the memo body, not the page.\n" + f"Page output snippet: {page_output[:2000]}" + ) + + +def test_hooks_only_var_data_descendant_inside_snapshot_boundary_is_memoized() -> None: + """Hook-bearing VarData without ``state`` still triggers snapshot memoization. + + Some frontend-only Vars contribute React hooks but do not carry a backend + state name. The snapshot-boundary subtree scan must catch those hooks-only + Vars so their hook lines land in the memo body instead of being suppressed + with the descendant. + """ + hook_var = Var(_js_expr="hookOnlyProbe")._replace( + merge_var_data=VarData(hooks={"const hookOnlyProbe = useHookOnly();": None}) + ) + child = Plain.create() + child.special_props = [hook_var] + boundary = LeafComponent.create(child) + + ctx, page_ctx = _compile_single_page(lambda: boundary) + memo_code = _compile_memo_module_text(ctx) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Hooks-only descendant should produce one boundary wrapper, got: " + f"{list(ctx.memoize_wrappers)}" + ) + assert "useHookOnly" in memo_code, ( + "Hooks-only VarData should be emitted in the memo body.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "useHookOnly" not in (page_ctx.output_code or ""), ( + "Hooks-only VarData leaked into the page module.\n" + f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + ) + + +def test_added_hook_descendant_inside_snapshot_boundary_is_memoized() -> None: + """Hooks from ``add_hooks`` descendants trigger snapshot memoization. + + ``add_hooks`` output does not necessarily appear in any Var or event + trigger. Snapshot boundaries must still wrap so the walker skips the + descendant and the hook lands in the memo body, matching the signal used + by ``MemoizationLeaf.create``. + """ + + class HookOnlyChild(Component): + tag = "HookOnlyChild" + library = "hook-only-child-lib" + + def add_hooks(self) -> list[str]: + """Add a hook line for the regression test. + + Returns: + The hook lines this component contributes. + """ + return ["const hookOnlyChild = useHookOnlyChild();"] + + boundary = LeafComponent.create(HookOnlyChild.create()) + assert _should_memoize(boundary) + + ctx, page_ctx = _compile_single_page(lambda: boundary) + memo_code = _compile_memo_module_text(ctx) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Added-hook descendant should produce one boundary wrapper, got: " + f"{list(ctx.memoize_wrappers)}" + ) + assert "useHookOnlyChild" in memo_code, ( + "add_hooks output should be emitted in the memo body.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "useHookOnlyChild" not in (page_ctx.output_code or ""), ( + "add_hooks output leaked into the page module.\n" + f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + ) + + +def _restricted_stateful_attr_factories() -> list: + from reflex_components_core.el.elements.forms import BaseInput, Textarea + from reflex_components_core.el.elements.media import SvgStyle + from reflex_components_core.el.elements.metadata import Base, Link, Meta + from reflex_components_core.el.elements.scripts import Script + + return [ + pytest.param(lambda: Meta.create(content=STATE_VAR), id="meta_content"), + pytest.param(lambda: Base.create(href=STATE_VAR), id="base_href"), + pytest.param(lambda: Link.create(href=STATE_VAR), id="link_href"), + pytest.param(lambda: Script.create(src=STATE_VAR), id="script_src"), + pytest.param(lambda: BaseInput.create(value=STATE_VAR), id="input_value"), + pytest.param(lambda: Textarea.create(value=STATE_VAR), id="textarea_value"), + pytest.param(lambda: SvgStyle.create(media=STATE_VAR), id="svg_style_media"), + ] + + +@pytest.mark.parametrize("factory", _restricted_stateful_attr_factories()) +def test_restricted_content_element_with_stateful_attribute_uses_snapshot( + factory: Callable[[], Component], +) -> None: + """Stateful attrs on restricted-content elements are isolated in snapshots.""" + ctx, page_ctx = _compile_single_page(factory) + memo_code = _compile_memo_module_text(ctx) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected one snapshot wrapper for a stateful restricted attr, got: " + f"{list(ctx.memoize_wrappers)}" + ) + assert "useTestState" not in (page_ctx.output_code or ""), ( + "Reactive hook marker for restricted attr should not leak to page output.\n" + f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + ) + assert "useTestState" in memo_code, ( + "Reactive hook marker for restricted attr should live in the memo body.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + + +def _real_recursive_false_factories() -> list: + from reflex_components_radix.primitives.dialog import DialogTrigger + from reflex_components_radix.primitives.drawer import DrawerTrigger + from reflex_components_radix.themes.components.popover import PopoverTrigger + from reflex_components_radix.themes.components.tabs import TabsTrigger + from reflex_components_radix.themes.components.tooltip import Tooltip + + return [ + pytest.param( + lambda: DialogTrigger.create(rx.text(STATE_VAR)), + id="primitive_dialog_trigger", + ), + pytest.param( + lambda: DrawerTrigger.create(rx.text(STATE_VAR)), + id="primitive_drawer_trigger", + ), + pytest.param( + lambda: PopoverTrigger.create(rx.text(STATE_VAR)), + id="themes_popover_trigger", + ), + pytest.param( + lambda: TabsTrigger.create(rx.text(STATE_VAR), value="tab-1"), + id="themes_tabs_trigger", + ), + pytest.param( + lambda: Tooltip.create(rx.text(STATE_VAR), content="tip"), + id="themes_tooltip", + ), + ] + + +@pytest.mark.parametrize("factory", _real_recursive_false_factories()) +def test_real_recursive_false_components_with_stateful_descendants_snapshot_wrap( + factory: Callable[[], Component], +) -> None: + """Several real ``recursive=False`` components share the boundary behavior.""" + component = factory() + ctx, page_ctx = _compile_single_page(lambda: component) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Expected one wrapper for {type(component).__qualname__}, got: " + f"{list(ctx.memoize_wrappers)}" + ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert type(component).__name__.lower() in wrapper_tag.lower(), ( + f"Wrapper {wrapper_tag!r} should be derived from {type(component).__name__}." + ) + assert "useContext(StateContexts" not in (page_ctx.output_code or ""), ( + "Stateful descendant under a real snapshot boundary leaked to page output.\n" + f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + ) + + +def test_restricted_content_element_with_id_and_stateful_child_still_memoizes() -> None: + """Static ref filtering must not suppress real stateful content.""" + from reflex_components_core.el.elements.metadata import Title + + title = Title.create(STATE_VAR, id="stateful-title") + ctx, page_ctx = _compile_single_page(lambda: title) + memo_code = _compile_memo_module_text(ctx) + + assert len(ctx.memoize_wrappers) == 1, ( + f"Stateful title with id should still memoize, got: {list(ctx.memoize_wrappers)}" + ) + assert "ref_stateful_title" not in (page_ctx.output_code or ""), ( + "The title ref should move with the snapshot body, not stay on the page.\n" + f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + ) + assert "ref_stateful_title" in memo_code, ( + "The title ref should be emitted inside the snapshot memo body.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + + +def test_each_memo_wrapper_emits_one_component_module_file() -> None: + """Every wrapper tag corresponds to exactly one ``components/{tag}.jsx`` file. + + Locks the per-wrapper file invariant: ``compile_memo_components`` must + emit one module per wrapper (plus the shared index), so that React can + code-split per wrapper. A wrapper without a file (or a file without a + wrapper) would mean broken imports at runtime. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, _page_ctx = _compile_single_page( + lambda: Fragment.create( + Plain.create(STATE_VAR), + WithProp.create(label=STATE_VAR), + LeafComponent.create(Plain.create(STATE_VAR)), + ) + ) + memo_files, _imports = compile_memo_components( + components=(), + experimental_memos=tuple(ctx.auto_memo_components.values()), + ) + component_module_names = { + Path(path).name + for path, _ in memo_files + if Path(path).parent.name == "components" + } + expected = {f"{tag}.jsx" for tag in ctx.memoize_wrappers} + assert component_module_names == expected, ( + f"Per-wrapper file invariant broken. wrappers={sorted(ctx.memoize_wrappers)} " + f"files={sorted(component_module_names)}" + ) + assert len(ctx.memoize_wrappers) >= 2, ( + "Test should exercise multi-wrapper case to be meaningful." + ) From 18b4cf4b63ba62a9e0c4eb5dd462cb61d43c2439 Mon Sep 17 00:00:00 2001 From: Masen Furer <m_github@0x26.net> Date: Wed, 29 Apr 2026 12:10:45 -0700 Subject: [PATCH 53/59] Address CR feedback --- .../src/reflex_base/components/component.py | 34 +- .../src/reflex_components_core/base/link.py | 11 +- .../el/elements/base.py | 27 ++ .../el/elements/forms.py | 11 +- .../el/elements/inline.py | 11 +- .../el/elements/media.py | 39 +- .../el/elements/metadata.py | 25 +- .../el/elements/scripts.py | 11 +- .../el/elements/tables.py | 7 +- .../el/elements/typography.py | 7 +- pyi_hashes.json | 26 +- reflex/compiler/plugins/memoize.py | 57 ++- .../test_memoize_edge_cases.py | 54 +-- tests/units/compiler/test_memoize_plugin.py | 407 +++++++++--------- 14 files changed, 351 insertions(+), 376 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 325329b72cf..79b562ddc78 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -25,14 +25,7 @@ from reflex_base.components.dynamic import load_dynamic_serializer from reflex_base.components.field import BaseField, FieldBasedMeta from reflex_base.components.tags import Tag -from reflex_base.constants import ( - Dirs, - EventTriggers, - Hooks, - Imports, - MemoizationDisposition, - MemoizationMode, -) +from reflex_base.constants import Dirs, EventTriggers, Hooks, Imports, MemoizationMode from reflex_base.constants.compiler import SpecialAttributes from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER from reflex_base.event import ( @@ -2459,30 +2452,15 @@ class MemoizationLeaf(Component): components within it, should be a memoization leaf so the compiler does not replace the provided child tags with memoized tags. - During creation, a memoization leaf will mark itself as wanting to be - memoized if any of its children return any hooks. + Whether the leaf is wrapped in a memo definition is decided by the + compiler's snapshot-boundary subtree scan, not by a class-local + disposition override — so leaves and components that explicitly set + ``_memoization_mode = MemoizationMode(recursive=False)`` are handled + identically. """ _memoization_mode = MemoizationMode(recursive=False) - @classmethod - def create(cls, *children, **props) -> Component: - """Create a new memoization leaf component. - - Args: - *children: The children of the component. - **props: The props of the component. - - Returns: - The memoization leaf - """ - comp = super().create(*children, **props) - if comp._get_all_hooks(): - comp._memoization_mode = dataclasses.replace( - comp._memoization_mode, disposition=MemoizationDisposition.ALWAYS - ) - return comp - load_dynamic_serializer() diff --git a/packages/reflex-components-core/src/reflex_components_core/base/link.py b/packages/reflex-components-core/src/reflex_components_core/base/link.py index 47fc812d0cf..78bd301bcb2 100644 --- a/packages/reflex-components-core/src/reflex_components_core/base/link.py +++ b/packages/reflex-components-core/src/reflex_components_core/base/link.py @@ -1,31 +1,26 @@ """Display the title of the current page.""" from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var -from reflex_components_core.el.elements.base import BaseHTML +from reflex_components_core.el.elements.base import RawTextBaseHTML, VoidBaseHTML -class RawLink(BaseHTML): +class RawLink(VoidBaseHTML): """A component that displays the title of the current page.""" tag = "link" - _memoization_mode = MemoizationMode(recursive=False) - href: Var[str] = field(doc="The href.") rel: Var[str] = field(doc="The type of link.") -class ScriptTag(BaseHTML): +class ScriptTag(RawTextBaseHTML): """A script tag with the specified type and source.""" tag = "script" - _memoization_mode = MemoizationMode(recursive=False) - type_: Var[str] = field(doc="The type of script represented.") source: Var[str] = field(doc="The URI of an external script.") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/base.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/base.py index e8b922f7430..476e3768edb 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/base.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/base.py @@ -3,6 +3,7 @@ from typing import Literal from reflex_base.components.component import field +from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.element import Element @@ -140,3 +141,29 @@ class BaseHTML(Element): ) title: Var[str] = field(doc="Defines a tooltip for the element.") + + +class RawTextBaseHTML(BaseHTML): + """Base class for HTML elements with raw-text (RCDATA) content models. + + Raw-text elements (``<title>``, ``<style>``, ``<textarea>``, ``<script>``, + ``<noscript>``) parse their content as text rather than child markup. A + JSX component child stringifies as ``[object Object]``, so a stateful + ``Bare`` child must be captured inside the element's snapshot body + rather than being independently memoized as a sibling JSX call. + """ + + _memoization_mode = MemoizationMode(recursive=False) + + +class VoidBaseHTML(BaseHTML): + """Base class for void HTML elements (no children allowed). + + Void elements (``<area>``, ``<base>``, ``<br>``, ``<col>``, ``<embed>``, + ``<hr>``, ``<img>``, ``<input>``, ``<link>``, ``<meta>``, ``<source>``, + ``<track>``, ``<wbr>``) cannot have children. A stateful ``Bare`` child + must stay inside the element's snapshot body rather than being + independently memoized into an invalid JSX call. + """ + + _memoization_mode = MemoizationMode(recursive=False) diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py index 30374ccf09d..aef4a4ebf14 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py @@ -9,7 +9,6 @@ from reflex_base.components.component import field from reflex_base.components.tags.tag import Tag from reflex_base.constants import Dirs, EventTriggers -from reflex_base.constants.compiler import MemoizationMode from reflex_base.event import ( FORM_DATA, EventChain, @@ -30,7 +29,7 @@ from reflex_components_core.el.element import Element -from .base import BaseHTML +from .base import BaseHTML, RawTextBaseHTML, VoidBaseHTML def _handle_submit_js_template( @@ -306,13 +305,11 @@ def _exclude_props(self) -> list[str]: ] -class BaseInput(BaseHTML): +class BaseInput(VoidBaseHTML): """A base class for input elements.""" tag = "input" - _memoization_mode = MemoizationMode(recursive=False) - accept: Var[str] = field(doc="Accepted types of files when the input is file type") alt: Var[str] = field(doc='Alternate text for input type="image"') @@ -655,13 +652,11 @@ class Select(BaseHTML): """ -class Textarea(BaseHTML): +class Textarea(RawTextBaseHTML): """Display the textarea element.""" tag = "textarea" - _memoization_mode = MemoizationMode(recursive=False) - auto_complete: Var[str] = field( doc="Whether the form control should have autocomplete enabled" ) diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py index 0adb1bbe31d..486f87dd609 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/inline.py @@ -3,10 +3,9 @@ from typing import ClassVar, Literal from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var -from .base import BaseHTML +from .base import BaseHTML, VoidBaseHTML ReferrerPolicy = Literal[ "", @@ -81,13 +80,11 @@ class Bdo(BaseHTML): tag = "bdo" -class Br(BaseHTML): +class Br(VoidBaseHTML): """Display the br element.""" tag = "br" - _memoization_mode = MemoizationMode(recursive=False) - class Cite(BaseHTML): """Display the cite element.""" @@ -223,13 +220,11 @@ class U(BaseHTML): tag = "u" -class Wbr(BaseHTML): +class Wbr(VoidBaseHTML): """Display the wbr element.""" tag = "wbr" - _memoization_mode = MemoizationMode(recursive=False) - a = A.create abbr = Abbr.create diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py index 89c4c19b3a5..66b7981ef92 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/media.py @@ -4,21 +4,18 @@ from reflex_base.components.component import Component, ComponentNamespace, field from reflex_base.constants.colors import Color -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.inline import ReferrerPolicy -from .base import BaseHTML +from .base import BaseHTML, RawTextBaseHTML, VoidBaseHTML -class Area(BaseHTML): +class Area(VoidBaseHTML): """Display the area element.""" tag = "area" - _memoization_mode = MemoizationMode(recursive=False) - alt: Var[str] = field(doc="Alternate text for the area, used for accessibility") coords: Var[str] = field(doc="Coordinates to define the shape of the area") @@ -81,13 +78,11 @@ class Audio(BaseHTML): ImageLoading = Literal["eager", "lazy"] -class Img(BaseHTML): +class Img(VoidBaseHTML): """Display the img element.""" tag = "img" - _memoization_mode = MemoizationMode(recursive=False) - alt: Var[str] = field(doc="Alternative text for the image") cross_origin: Var[CrossOrigin] = field( @@ -140,13 +135,11 @@ class Map(BaseHTML): ) -class Track(BaseHTML): +class Track(VoidBaseHTML): """Display the track element.""" tag = "track" - _memoization_mode = MemoizationMode(recursive=False) - default: Var[bool] = field( doc="Indicates that the track should be enabled unless the user's preferences indicate otherwise" ) @@ -194,13 +187,11 @@ class Video(BaseHTML): src: Var[str] = field(doc="URL of the video to play") -class Embed(BaseHTML): +class Embed(VoidBaseHTML): """Display the embed element.""" tag = "embed" - _memoization_mode = MemoizationMode(recursive=False) - src: Var[str] = field(doc="URL of the embedded content") type: Var[str] = field(doc="Media type of the embedded content") @@ -260,13 +251,11 @@ class Portal(BaseHTML): tag = "portal" -class Source(BaseHTML): +class Source(VoidBaseHTML): """Display the source element.""" tag = "source" - _memoization_mode = MemoizationMode(recursive=False) - media: Var[str] = field( doc="Media query indicating what device the linked resource is optimized for" ) @@ -881,21 +870,17 @@ class MPath(BaseHTML): href: Var[str] = field(doc="Reference to a path element.") -class Desc(BaseHTML): +class Desc(RawTextBaseHTML): """The SVG desc component for descriptions.""" tag = "desc" - _memoization_mode = MemoizationMode(recursive=False) - -class Title(BaseHTML): +class Title(RawTextBaseHTML): """The SVG title component for titles.""" tag = "title" - _memoization_mode = MemoizationMode(recursive=False) - class Metadata(BaseHTML): """The SVG metadata component for metadata.""" @@ -903,13 +888,11 @@ class Metadata(BaseHTML): tag = "metadata" -class Script(BaseHTML): +class Script(RawTextBaseHTML): """The SVG script component for scripts.""" tag = "script" - _memoization_mode = MemoizationMode(recursive=False) - type: Var[str] = field(doc="MIME type of the script.") href: Var[str] = field(doc="URL of external script.") @@ -917,13 +900,11 @@ class Script(BaseHTML): crossorigin: Var[str] = field(doc="CORS settings for the script.") -class SvgStyle(BaseHTML): +class SvgStyle(RawTextBaseHTML): """The SVG style component for stylesheets.""" tag = "style" - _memoization_mode = MemoizationMode(recursive=False) - type: Var[str] = field(doc="MIME type of the stylesheet.") media: Var[str] = field(doc="Media query for the stylesheet.") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py index 639518f208d..f6211538c8f 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.py @@ -1,23 +1,20 @@ """Metadata classes.""" -from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode +from reflex_base.components.component import MemoizationLeaf, field from reflex_base.vars.base import Var from reflex_components_core.el.element import Element from reflex_components_core.el.elements.inline import ReferrerPolicy from reflex_components_core.el.elements.media import CrossOrigin -from .base import BaseHTML +from .base import BaseHTML, VoidBaseHTML -class Base(BaseHTML): +class Base(VoidBaseHTML): """Display the base element.""" tag = "base" - _memoization_mode = MemoizationMode(recursive=False) - href: Var[str] target: Var[str] @@ -28,13 +25,11 @@ class Head(BaseHTML): tag = "head" -class Link(BaseHTML): +class Link(VoidBaseHTML): """Display the link element.""" tag = "link" - _memoization_mode = MemoizationMode(recursive=False) - cross_origin: Var[CrossOrigin] = field( doc="Specifies the CORS settings for the linked resource" ) @@ -66,13 +61,11 @@ class Link(BaseHTML): type: Var[str] = field(doc="Specifies the MIME type of the linked document") -class Meta(BaseHTML): # Inherits common attributes from BaseHTML +class Meta(VoidBaseHTML): # Inherits common attributes from BaseHTML """Display the meta element.""" tag = "meta" # The HTML tag for this element is <meta> - _memoization_mode = MemoizationMode(recursive=False) - char_set: Var[str] = field( doc="Specifies the character encoding for the HTML document" ) @@ -88,16 +81,14 @@ class Meta(BaseHTML): # Inherits common attributes from BaseHTML property: Var[str] = field(doc="The type of metadata value.") -class Title(Element): +class Title(MemoizationLeaf, Element): """Display the title element.""" tag = "title" - _memoization_mode = MemoizationMode(recursive=False) - # Had to be named with an underscore so it doesn't conflict with reflex.style Style in pyi -class StyleEl(Element): +class StyleEl(MemoizationLeaf, Element): """Display the style element.""" tag = "style" @@ -106,8 +97,6 @@ class StyleEl(Element): suppress_hydration_warning: Var[bool] = Var.create(True) - _memoization_mode = MemoizationMode(recursive=False) - base = Base.create head = Head.create diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py index 0170f8f6f05..8bc4341b6cb 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.py @@ -1,13 +1,12 @@ """Scripts classes.""" from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var from reflex_components_core.el.elements.inline import ReferrerPolicy from reflex_components_core.el.elements.media import CrossOrigin -from .base import BaseHTML +from .base import BaseHTML, RawTextBaseHTML class Canvas(BaseHTML): @@ -16,21 +15,17 @@ class Canvas(BaseHTML): tag = "canvas" -class Noscript(BaseHTML): +class Noscript(RawTextBaseHTML): """Display the noscript element.""" tag = "noscript" - _memoization_mode = MemoizationMode(recursive=False) - -class Script(BaseHTML): +class Script(RawTextBaseHTML): """Display the script element.""" tag = "script" - _memoization_mode = MemoizationMode(recursive=False) - async_: Var[bool] = field( doc="Indicates that the script should be executed asynchronously" ) diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py index b1915feac87..3aab7bd0d2d 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/tables.py @@ -3,10 +3,9 @@ from typing import Literal from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var -from .base import BaseHTML +from .base import BaseHTML, VoidBaseHTML class Caption(BaseHTML): @@ -15,13 +14,11 @@ class Caption(BaseHTML): tag = "caption" -class Col(BaseHTML): +class Col(VoidBaseHTML): """Display the col element.""" tag = "col" - _memoization_mode = MemoizationMode(recursive=False) - span: Var[int] = field(doc="Number of columns the col element spans") diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py index 3c7f4129853..b891201495e 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/typography.py @@ -3,10 +3,9 @@ from typing import ClassVar, Literal from reflex_base.components.component import field -from reflex_base.constants.compiler import MemoizationMode from reflex_base.vars.base import Var -from .base import BaseHTML +from .base import BaseHTML, VoidBaseHTML class Blockquote(BaseHTML): @@ -53,13 +52,11 @@ class Figure(BaseHTML): tag = "figure" -class Hr(BaseHTML): +class Hr(VoidBaseHTML): """Display the hr element.""" tag = "hr" - _memoization_mode = MemoizationMode(recursive=False) - class Li(BaseHTML): """Display the li element.""" diff --git a/pyi_hashes.json b/pyi_hashes.json index 830825697f6..db5a99505e0 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -8,7 +8,7 @@ "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": "210db7b91e03f14dd3e0b459cf1eea8e", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "005866cf4d1cc8ac7693ed6baeca2289", "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", @@ -26,16 +26,16 @@ "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": "dc724d75a5d41ea9084f8836a3546f15", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "25a1c158b95d999b12dd443448a49567", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "37b3bd1435fde95c61846709022b2e45", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "2ea7fd8581eb30e3e18719d28f070725", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "a3ef8bcb5fe8e4bfb22a8f6d714611b8", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "ab968cdfc51968d6c0c4e8a884c4f246", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "9c1432e70e6b9349f44df04a244a4303", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f51120c31a1a8b79da9ecf58f19005b9", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "73d19f3d9e389447ad8bbb68e1b7d1c9", "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": "ed0a544175a1d686b4f962919d42b17a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "903432e316a781b342f2b8d334952da1", "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": "72b22d1b8b543e1450652445d9083b80", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "65f9fafd636741c0a2927b84e8c5180b", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "93a69aab9a6f519e3f293d439a39786b", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "2b434f2231d6f21b12d32995ac185e79", "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", @@ -114,14 +114,10 @@ "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/general.pyi": "5a1a479924ad6184abafe4d796cb04c5", "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-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", - "packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.pyi": "542ccba14de2456c1a046697982e0147", - "packages/reflex-site-shared/src/reflex_site_shared/components/image_zoom.pyi": "3999125aeb7c1768495659b20b033f54", - "packages/reflex-site-shared/src/reflex_site_shared/components/marketing_button.pyi": "74b01fba2002f202c07c005195c67dc8", - "packages/reflex-site-shared/src/reflex_site_shared/components/marquee.pyi": "596f0121f0bd409500da85cdd842a35d", "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e" diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index c7700342526..52c190c313e 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -72,7 +72,7 @@ def _compute_memo_tag(component: Component) -> str | None: def _subtree_has_reactive_data( - component: Component, _seen: set[int] | None = None + component: Component, _cache: dict[int, bool] | None = None ) -> bool: """Whether ``component``'s subtree carries reactive signals worth memoizing. @@ -86,21 +86,54 @@ def _subtree_has_reactive_data( Args: component: The component whose subtree to inspect. - _seen: Internal ``id()`` set to avoid revisiting the same component - via overlapping ``var_data.components`` and ``children`` paths. + _cache: Internal ``id()``-keyed cache of per-subtree results so + components reachable via overlapping ``var_data.components`` and + ``children`` paths are evaluated once. ``False`` is also used as + a transient placeholder while a subtree is being computed to + break cycles. Returns: True if the subtree carries event triggers, explicit hooks, or any Var whose merged var_data has ``state`` or ``hooks``. """ - if _seen is None: - _seen = set() - if id(component) in _seen: - return False - _seen.add(id(component)) + if _cache is None: + _cache = {} + key = id(component) + cached = _cache.get(key) + if cached is not None: + return cached + # Placeholder breaks cycles: a subtree that references itself is + # treated as non-reactive on the recursive arm; the real result for + # this node is written back below. + _cache[key] = False + result = _component_subtree_is_reactive(component, _cache) + _cache[key] = result + return result + + +def _component_subtree_is_reactive( + component: Component, _cache: dict[int, bool] +) -> bool: + """Inner walk for :func:`_subtree_has_reactive_data` (uncached node check). - if component.event_triggers: - return True + Internal hooks (``_get_hooks_internal``) cover event-trigger callbacks, + lifecycle hooks (``on_mount``/``on_unmount``), and Var-derived hooks + (state context, client state, custom). The static ``id`` ref hook is + explicitly subtracted so an id-only element does not flag as reactive. + + Args: + component: The component to inspect. + _cache: Shared cache passed through recursive calls. + + Returns: + True if ``component`` itself or any reachable descendant carries + reactive signals. + """ + ref_hook = component._get_ref_hook() + ref_hook_key = str(ref_hook) if ref_hook is not None else None + for hook_key in component._get_hooks_internal(): + if hook_key != ref_hook_key: + return True if component._get_hooks() is not None or component._get_added_hooks(): return True for var in component._get_vars(include_children=False): @@ -110,10 +143,10 @@ def _subtree_has_reactive_data( if var_data.state or var_data.hooks: return True for comp in var_data.components: - if isinstance(comp, Component) and _subtree_has_reactive_data(comp, _seen): + if isinstance(comp, Component) and _subtree_has_reactive_data(comp, _cache): return True for child in component.children: - if isinstance(child, Component) and _subtree_has_reactive_data(child, _seen): + if isinstance(child, Component) and _subtree_has_reactive_data(child, _cache): return True return False diff --git a/tests/integration/tests_playwright/test_memoize_edge_cases.py b/tests/integration/tests_playwright/test_memoize_edge_cases.py index abf2ea099c2..0d4e92e5f9c 100644 --- a/tests/integration/tests_playwright/test_memoize_edge_cases.py +++ b/tests/integration/tests_playwright/test_memoize_edge_cases.py @@ -14,9 +14,9 @@ inside the parent's body. Test design notes: -- ``document.title`` is not a reliable signal: React Router writes a - metadata title alongside any user-rendered ``<title>``. Tests inspect the - ``<title>`` element directly rather than ``document.title``. +- The page title is supplied via ``app.add_page(..., title=MemoState.title_marker)`` + so the dynamic value flows through the standard React Router metadata path + and shows up in ``document.title``. - Style content is matched on a unique marker substring rather than common selectors like ``body`` (which conflicts with Emotion/Sonner stylesheets). - ``<textarea>``'s runtime value semantics belong to React (children are @@ -60,7 +60,6 @@ def bump(self): def index(): return rx.box( - rx.el.title(MemoState.title_marker), rx.el.style("body { --memo-marker: " + MemoState.css_marker + "; }"), rx.box( rx.button("toggle", on_click=MemoState.toggle_open, id="toggle"), @@ -88,7 +87,7 @@ def index(): ) app = rx.App() - app.add_page(index) + app.add_page(index, title=MemoState.title_marker) @pytest.fixture(scope="module") @@ -138,36 +137,34 @@ def test_accordion_trigger_with_stateful_cond_updates( expect(page.locator("#trigger-show")).to_have_text("Show") -def _document_contains(page: Page, marker: str) -> bool: - """Whether any ``<title>`` or ``<style>`` element contains ``marker``. +def _document_contains_style(page: Page, marker: str) -> bool: + """Whether any ``<style>`` element's text content contains ``marker``. - ``<title>``/``<style>`` content is metadata, not "visible" text, so the - Locator ``has_text`` filter skips them. Inspect text content via JS. + ``<style>`` content is not "visible" text, so the Locator ``has_text`` + filter skips it. Inspect text content via JS instead. Args: page: Playwright page. - marker: Substring to look for in title/style element text content. + marker: Substring to look for in style element text content. Returns: - True if any title/style element's textContent contains the marker. + True if any ``<style>`` element's textContent contains the marker. """ return page.evaluate( """(marker) => { - const els = document.querySelectorAll('title, style'); + const els = document.querySelectorAll('style'); return Array.from(els).some(el => (el.textContent || '').includes(marker)); }""", marker, ) -def test_title_element_renders_stateful_var_as_text( - memo_app: AppHarness, page: Page -) -> None: - """``rx.el.title(state_var)`` writes the state value as the title's text. +def test_page_title_updates_with_state(memo_app: AppHarness, page: Page) -> None: + """The page title (passed to ``add_page(title=...)``) tracks state. - Verified by reading the title element's textContent directly. A passing - test means the state value lands as the title's text node, not a JSX - component child that would be stringified. + Verifying via ``document.title`` proves the state value flows through the + standard page-metadata path and lands as the title's text node, not as a + stringified JSX component child. Args: memo_app: Running app harness. @@ -177,17 +174,10 @@ def test_title_element_renders_stateful_var_as_text( page.goto(memo_app.frontend_url) page.wait_for_selector("#trigger-show") - assert _document_contains(page, "memo-title-home") - assert not _document_contains(page, "memo-title-about") + expect(page).to_have_title("memo-title-home") page.click("#set-title") - page.wait_for_function( - """() => Array.from(document.querySelectorAll('title')) - .some(el => (el.textContent || '').includes('memo-title-about'))""", - timeout=5000, - ) - assert _document_contains(page, "memo-title-about") - assert not _document_contains(page, "memo-title-home") + expect(page).to_have_title("memo-title-about") def test_style_element_renders_stateful_css_as_text( @@ -206,8 +196,8 @@ def test_style_element_renders_stateful_css_as_text( page.goto(memo_app.frontend_url) page.wait_for_selector("#trigger-show") - assert _document_contains(page, "memo-css-light") - assert not _document_contains(page, "memo-css-dark") + assert _document_contains_style(page, "memo-css-light") + assert not _document_contains_style(page, "memo-css-dark") page.click("#set-css") page.wait_for_function( @@ -215,5 +205,5 @@ def test_style_element_renders_stateful_css_as_text( .some(el => (el.textContent || '').includes('memo-css-dark'))""", timeout=5000, ) - assert _document_contains(page, "memo-css-dark") - assert not _document_contains(page, "memo-css-light") + assert _document_contains_style(page, "memo-css-dark") + assert not _document_contains_style(page, "memo-css-light") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index cc094385eb6..56308005e42 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -19,6 +19,24 @@ from reflex_base.vars.base import LiteralVar, Var from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment +from reflex_components_core.base.link import RawLink, ScriptTag +from reflex_components_core.el.elements.forms import BaseInput, Textarea +from reflex_components_core.el.elements.inline import Br, Wbr +from reflex_components_core.el.elements.media import ( + Area, + Desc, + Embed, + Img, + Source, + SvgStyle, + Track, +) +from reflex_components_core.el.elements.media import Script as SvgScript +from reflex_components_core.el.elements.media import Title as SvgTitle +from reflex_components_core.el.elements.metadata import Base, Link, Meta, StyleEl, Title +from reflex_components_core.el.elements.scripts import Noscript, Script +from reflex_components_core.el.elements.tables import Col +from reflex_components_core.el.elements.typography import Hr import reflex as rx import reflex.compiler.plugins.memoize as memoize_plugin @@ -198,10 +216,12 @@ def special_child() -> Component: memo_code = "\n".join(code for _, code in memo_files) state_wiring = "useContext(StateContexts" + page_output = page_ctx.output_code + assert page_output is not None assert state_wiring in memo_code - assert state_wiring not in (page_ctx.output_code or "") + assert state_wiring not in page_output assert body_marker in memo_code - assert body_marker not in (page_ctx.output_code or "") + assert body_marker not in page_output def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: @@ -256,28 +276,6 @@ def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: assert get_memoization_strategy(form) is MemoizationStrategy.PASSTHROUGH -def test_memoization_leaf_suppresses_descendant_wrapping() -> None: - """A snapshot boundary owns its subtree: descendants never wrap independently. - - The boundary itself wraps once because its subtree is stateful (see - ``test_snapshot_boundary_wraps_subtree_once_when_descendant_is_stateful``). - The point of this test is the suppression invariant — only one wrapper - exists and it covers the boundary, never a separate wrapper for the - inner ``Plain(STATE_VAR)``. - """ - ctx, _page_ctx = _compile_single_page( - lambda: LeafComponent.create( - Plain.create(STATE_VAR), # would otherwise be independently memoized - ) - ) - assert len(ctx.memoize_wrappers) == 1 - wrapper_tag = next(iter(ctx.memoize_wrappers)) - assert "leafcomponent" in wrapper_tag.lower(), ( - f"The single wrapper should cover the leaf, not its descendant. " - f"Got: {wrapper_tag!r}" - ) - - 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( @@ -1185,8 +1183,7 @@ def test_snapshot_boundary_with_event_trigger_descendant_is_wrapped() -> None: _var_type=EventChain, merge_var_data=VarData(state="TestState", hooks={"useTestState": None}), ) - inner = Plain.create() - inner.event_triggers["on_click"] = event_var + inner = Plain.create(on_click=event_var) boundary = LeafComponent.create(inner) assert _should_memoize(boundary), ( "Snapshot boundary with a stateful event-trigger descendant must memoize." @@ -1217,8 +1214,6 @@ def test_title_with_stateful_var_child_does_not_wrap_bare_independently() -> Non snapshot boundary keeps the Bare inside the title's snapshot, where it renders as a text interpolation. """ - from reflex_components_core.el.elements.metadata import Title - title = Title.create(STATE_VAR) ctx, page_ctx = _compile_single_page(lambda: title) @@ -1230,10 +1225,18 @@ def test_title_with_stateful_var_child_does_not_wrap_bare_independently() -> Non assert wrapper_tag.lower().startswith("title_"), ( f"Wrapper should be derived from Title, got: {wrapper_tag!r}" ) - output = page_ctx.output_code or "" - assert "Bare_comp" not in output, ( - "Bare must not be independently memoized as a child of <title>. " - "It needs to remain a text interpolation inside the title's snapshot.\n" + output = page_ctx.output_code + assert output is not None + assert f"jsx({wrapper_tag}," in output, ( + "The page output must call the snapshot wrapper.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "useTestState" not in output, ( + "The state-bearing hook should live inside the memo body, not the page.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "TestState" not in output, ( + "The state-context wiring should live inside the memo body, not the page.\n" f"Page output snippet: {output[:2000]}" ) @@ -1245,8 +1248,6 @@ def test_meta_with_stateful_var_child_does_not_wrap_bare_independently() -> None the Bare independently produces ``jsx("meta", {}, jsx(Bare_xxx, {}))`` which is invalid HTML. """ - from reflex_components_core.el.elements.metadata import Meta - meta = Meta.create(STATE_VAR) ctx, page_ctx = _compile_single_page(lambda: meta) @@ -1254,26 +1255,31 @@ def test_meta_with_stateful_var_child_does_not_wrap_bare_independently() -> None "Expected exactly one snapshot wrapper for the meta; got: " f"{list(ctx.memoize_wrappers)}" ) - output = page_ctx.output_code or "" - assert "Bare_comp" not in output, ( - "Bare must not be independently memoized as a child of <meta>.\n" + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code + assert output is not None + assert f"jsx({wrapper_tag}," in output, ( + "The page output must call the meta's snapshot wrapper.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "useTestState" not in output, ( + "The state-bearing hook should live inside the memo body, not the page.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "TestState" not in output, ( + "The state-context wiring should live inside the memo body, not the page.\n" f"Page output snippet: {output[:2000]}" ) -def _text_only_classes() -> list: - from reflex_components_core.el.elements.forms import Textarea - from reflex_components_core.el.elements.metadata import StyleEl - from reflex_components_core.el.elements.scripts import Script - - return [ +@pytest.mark.parametrize( + "cls", + [ pytest.param(StyleEl, id="style"), pytest.param(Textarea, id="textarea"), pytest.param(Script, id="script"), - ] - - -@pytest.mark.parametrize("cls", _text_only_classes()) + ], +) def test_text_only_element_with_stateful_var_child_does_not_wrap_bare( cls: type[Component], ) -> None: @@ -1289,10 +1295,20 @@ def test_text_only_element_with_stateful_var_child_does_not_wrap_bare( assert len(ctx.memoize_wrappers) == 1, ( f"Expected exactly one snapshot wrapper; got: {list(ctx.memoize_wrappers)}" ) - output = page_ctx.output_code or "" - assert "Bare_comp" not in output, ( - "Bare must not be independently memoized as a child of a raw-text " - f"element.\nPage output snippet: {output[:2000]}" + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code + assert output is not None + assert f"jsx({wrapper_tag}," in output, ( + "The page output must call the raw-text element's snapshot wrapper.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "useTestState" not in output, ( + "The state-bearing hook must live inside the memo body, not the page.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "TestState" not in output, ( + "The state-context wiring must live inside the memo body, not the page.\n" + f"Page output snippet: {output[:2000]}" ) @@ -1321,7 +1337,8 @@ def test_accordion_trigger_with_stateful_cond_is_memoized() -> None: f"wrapper. Got wrappers: {wrapper_tags}" ) - output = page_ctx.output_code or "" + output = page_ctx.output_code + assert output is not None assert "useContext(StateContexts" not in output, ( "State read leaked into the page module — the trigger's stateful cond " "should be captured inside the snapshot wrapper instead.\n" @@ -1329,71 +1346,38 @@ def test_accordion_trigger_with_stateful_cond_is_memoized() -> None: ) -def _restricted_content_components() -> list: - """Build factories for all components flagged ``recursive=False`` in this PR. - - Returns: - Parameterized factories yielding ``Component`` instances when called - with ``STATE_VAR`` as a child. - """ - from reflex_components_core.base.link import RawLink, ScriptTag - from reflex_components_core.el.elements.forms import BaseInput, Textarea - from reflex_components_core.el.elements.inline import Br, Wbr - from reflex_components_core.el.elements.media import ( - Area, - Desc, - Embed, - Img, - Source, - SvgStyle, - Track, - ) - from reflex_components_core.el.elements.media import Script as SvgScript - from reflex_components_core.el.elements.media import Title as SvgTitle - from reflex_components_core.el.elements.metadata import ( - Base, - Link, - Meta, - StyleEl, - Title, - ) - from reflex_components_core.el.elements.scripts import Noscript, Script - from reflex_components_core.el.elements.tables import Col - from reflex_components_core.el.elements.typography import Hr - - cases: list[tuple[str, type]] = [ +@pytest.mark.parametrize( + "component_cls", + [ # text-only (RCDATA / raw text) - ("title", Title), - ("style", StyleEl), - ("textarea", Textarea), - ("script", Script), - ("noscript", Noscript), - ("script_tag", ScriptTag), + pytest.param(Title, id="title"), + pytest.param(StyleEl, id="style"), + pytest.param(Textarea, id="textarea"), + pytest.param(Script, id="script"), + pytest.param(Noscript, id="noscript"), + pytest.param(ScriptTag, id="script_tag"), # void HTML elements - ("meta", Meta), - ("base", Base), - ("link", Link), - ("raw_link", RawLink), - ("input", BaseInput), - ("br", Br), - ("wbr", Wbr), - ("col", Col), - ("hr", Hr), - ("area", Area), - ("img", Img), - ("track", Track), - ("embed", Embed), - ("source", Source), + pytest.param(Meta, id="meta"), + pytest.param(Base, id="base"), + pytest.param(Link, id="link"), + pytest.param(RawLink, id="raw_link"), + pytest.param(BaseInput, id="input"), + pytest.param(Br, id="br"), + pytest.param(Wbr, id="wbr"), + pytest.param(Col, id="col"), + pytest.param(Hr, id="hr"), + pytest.param(Area, id="area"), + pytest.param(Img, id="img"), + pytest.param(Track, id="track"), + pytest.param(Embed, id="embed"), + pytest.param(Source, id="source"), # SVG raw-text equivalents - ("svg_desc", Desc), - ("svg_title", SvgTitle), - ("svg_script", SvgScript), - ("svg_style", SvgStyle), - ] - return [pytest.param(cls, id=name) for name, cls in cases] - - -@pytest.mark.parametrize("component_cls", _restricted_content_components()) + pytest.param(Desc, id="svg_desc"), + pytest.param(SvgTitle, id="svg_title"), + pytest.param(SvgScript, id="svg_script"), + pytest.param(SvgStyle, id="svg_style"), + ], +) def test_restricted_content_element_isolates_stateful_bare_via_snapshot( component_cls: type[Component], ) -> None: @@ -1415,18 +1399,28 @@ def test_restricted_content_element_isolates_stateful_bare_via_snapshot( ) ctx, page_ctx = _compile_single_page(lambda: component_cls.create(STATE_VAR)) - output = page_ctx.output_code or "" - - assert "Bare_comp" not in output, ( - f"Stateful Bare child of <{getattr(component_cls, 'tag', '?')}> " - f"({component_cls.__qualname__}) was independently wrapped. The " - "element's snapshot must capture the Bare inline.\n" - f"Page output snippet: {output[:2000]}" - ) assert len(ctx.memoize_wrappers) == 1, ( f"Expected exactly one snapshot wrapper for {component_cls.__qualname__}, " f"got: {list(ctx.memoize_wrappers)}" ) + wrapper_tag = next(iter(ctx.memoize_wrappers)) + output = page_ctx.output_code + assert output is not None + assert f"jsx({wrapper_tag}," in output, ( + f"Page output for {component_cls.__qualname__} must call the snapshot " + f"wrapper.\nPage output snippet: {output[:2000]}" + ) + assert "useTestState" not in output, ( + f"Stateful Bare child of <{getattr(component_cls, 'tag', '?')}> " + f"({component_cls.__qualname__}) leaked the state hook into the page; " + "the element's snapshot must capture it.\n" + f"Page output snippet: {output[:2000]}" + ) + assert "TestState" not in output, ( + f"State-context wiring for <{getattr(component_cls, 'tag', '?')}> " + f"({component_cls.__qualname__}) leaked into the page module.\n" + f"Page output snippet: {output[:2000]}" + ) def _compile_memo_module_text(ctx: CompileContext) -> str: @@ -1450,13 +1444,10 @@ def _compile_memo_module_text(ctx: CompileContext) -> str: def test_title_memo_body_renders_text_interpolation_not_bare_component() -> None: """The title's memo body must interpolate the state Var as text. - Concretely: the body should reference the stateful identifier (e.g. - ``"value"``-bearing context wiring) inside a ``jsx("title", {}, …)`` call, - and must not contain ``jsx(Bare_comp_…``. Combined with the page-side - "no Bare_comp" assertion, this proves the snapshot keeps the Bare inline. + The body must contain a literal ``jsx("title", …)`` call carrying the + state-context wiring, and the page module must subscribe via the wrapper + rather than directly. The state hook/context lives in the memo body only. """ - from reflex_components_core.el.elements.metadata import Title - ctx, page_ctx = _compile_single_page(lambda: Title.create(STATE_VAR)) memo_code = _compile_memo_module_text(ctx) @@ -1464,24 +1455,28 @@ def test_title_memo_body_renders_text_interpolation_not_bare_component() -> None f'Title snapshot body should contain a literal ``jsx("title", …)`` ' f"call. Memo code:\n{memo_code[:2000]}" ) - assert "Bare_comp" not in memo_code, ( - "Title memo body should not nest an independently-memoized Bare " - f"component.\nMemo code:\n{memo_code[:2000]}" + assert "useTestState" in memo_code, ( + "Title memo body should carry the stateful hook so the Bare child is " + f"interpolated inline, not lifted out.\nMemo code:\n{memo_code[:2000]}" ) - page_output = page_ctx.output_code or "" - assert "Bare_comp" not in page_output + page_output = page_ctx.output_code + assert page_output is not None + wrapper_tag = next(iter(ctx.memoize_wrappers)) + assert f"jsx({wrapper_tag}," in page_output + assert "useTestState" not in page_output def test_meta_memo_body_renders_void_element_inline() -> None: - """Meta's snapshot body should call ``jsx("meta", …)`` with no nested Bare.""" - from reflex_components_core.el.elements.metadata import Meta - + """Meta's snapshot body should call ``jsx("meta", …)`` and own the state.""" ctx, _page_ctx = _compile_single_page(lambda: Meta.create(STATE_VAR)) memo_code = _compile_memo_module_text(ctx) assert 'jsx("meta"' in memo_code - assert "Bare_comp" not in memo_code + assert "useTestState" in memo_code, ( + "Meta memo body should carry the stateful hook so the Bare child is " + f"interpolated inline, not lifted out.\nMemo code:\n{memo_code[:2000]}" + ) def test_snapshot_boundary_memo_body_subscribes_state_in_body_not_page() -> None: @@ -1507,7 +1502,8 @@ def test_snapshot_boundary_memo_body_subscribes_state_in_body_not_page() -> None assert "useContext(StateContexts" in memo_code, ( "Snapshot wrapper should subscribe to state inside the memo body." ) - page_output = page_ctx.output_code or "" + page_output = page_ctx.output_code + assert page_output is not None assert "useContext(StateContexts" not in page_output, ( "State subscription should NOT appear in the page module — it must be " "isolated inside the snapshot wrapper.\n" @@ -1531,6 +1527,18 @@ def test_nested_snapshot_boundaries_produce_one_outer_wrapper() -> None: f"{list(ctx.memoize_wrappers)}" ) + memo_code = _compile_memo_module_text(ctx) + assert "jsx(LeafComponent" in memo_code, ( + "The outer wrapper's body must render the inner LeafComponent so the " + "suppressed inner boundary still appears under the outer snapshot.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "jsx(Plain" in memo_code, ( + "The outer wrapper's body must render the innermost Plain component " + "and its Bare child so the stateful subtree lands inside the snapshot.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + def test_memoization_leaf_subclass_and_raw_recursive_false_behave_identically() -> None: """Both ways to opt into recursive=False produce one snapshot wrapper. @@ -1575,6 +1583,16 @@ def test_snapshot_boundary_with_multiple_stateful_descendants_emits_one_wrapper( f"{list(ctx.memoize_wrappers)}" ) + memo_code = _compile_memo_module_text(ctx) + assert memo_code.count("jsx(Plain") == 2, ( + "The boundary's snapshot body must render both Plain children inline.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + assert "jsx(WithProp" in memo_code, ( + "The boundary's snapshot body must render the WithProp child inline.\n" + f"Memo code snippet: {memo_code[:2000]}" + ) + def test_repeated_snapshot_boundary_subtrees_dedupe_to_one_definition() -> None: """Two identical boundary subtrees collapse to one memo definition. @@ -1597,24 +1615,6 @@ def test_repeated_snapshot_boundary_subtrees_dedupe_to_one_definition() -> None: assert (page_ctx.output_code or "").count(f"jsx({wrapper_tag},") == 2 -def test_passthrough_wrapper_inside_snapshot_boundary_is_suppressed() -> None: - """Passthrough-eligible descendants of a snapshot boundary are suppressed. - - Without suppression, the descendant would emit its own wrapper that the - boundary's snapshot then references — which works visually but defeats - the "boundary owns the subtree" contract and pollutes the wrapper list. - """ - # Plain.create(STATE_VAR) on its own is a passthrough memo candidate. - boundary = LeafComponent.create(Plain.create(STATE_VAR)) - ctx, _page_ctx = _compile_single_page(lambda: boundary) - assert len(ctx.memoize_wrappers) == 1 - wrapper_tag = next(iter(ctx.memoize_wrappers)) - assert "leafcomponent" in wrapper_tag.lower(), ( - "The single wrapper should be the boundary's, not a separate " - f"passthrough wrapper for the descendant. Got: {wrapper_tag!r}" - ) - - def test_snapshot_boundary_with_stateful_prop_and_descendant_emits_one_wrapper() -> ( None ): @@ -1674,16 +1674,14 @@ def test_void_element_with_only_stateful_prop_memoizes_via_snapshot() -> None: img = Img.create(src=STATE_VAR.to(str)) ctx, page_ctx = _compile_single_page(lambda: img) assert len(ctx.memoize_wrappers) == 1 - assert "useContext(StateContexts" not in (page_ctx.output_code or "") + output = page_ctx.output_code + assert output is not None + assert "useContext(StateContexts" not in output -def _static_id_only_factories() -> list: - from reflex_components_core.el.elements.forms import BaseInput - from reflex_components_core.el.elements.inline import Br - from reflex_components_core.el.elements.media import Img - from reflex_components_core.el.elements.metadata import Meta, Title - - return [ +@pytest.mark.parametrize( + "factory", + [ pytest.param(lambda: Title.create("hello", id="t"), id="title_with_id"), pytest.param(lambda: Img.create(src="/x.png", id="logo"), id="img_with_id"), pytest.param(lambda: Br.create(id="br"), id="br_with_id"), @@ -1691,17 +1689,17 @@ def _static_id_only_factories() -> list: pytest.param( lambda: Meta.create(name="description", id="m"), id="meta_with_id" ), - ] - - -@pytest.mark.parametrize("factory", _static_id_only_factories()) + ], +) def test_static_restricted_element_with_id_only_does_not_memoize( factory: Callable[[], Component], ) -> None: - """Restricted-content elements with only an ``id`` ref and no state stay unwrapped. + """Restricted-content elements with only an ``id`` ref stay unwrapped. - The subtree scan filters on state/hooks-bearing var_data so ``useRef`` - lines from ``id`` props alone do not trigger wrapping. + The subtree scan subtracts the static-id ``useRef`` line from the + component's internal hooks so id-only elements do not flag as reactive. + Both ``MemoizationLeaf``-based elements and components that explicitly + set ``_memoization_mode = recursive=False`` go through this same path. """ component = factory() ctx, _page_ctx = _compile_single_page(lambda: component) @@ -1721,26 +1719,29 @@ def test_static_restricted_element_no_id_no_children_does_not_memoize() -> None: ) -def test_client_state_value_inside_snapshot_boundary_is_memoized() -> None: +@pytest.mark.parametrize("global_ref", [True, False]) +def test_client_state_value_inside_snapshot_boundary_is_memoized( + global_ref: bool, +) -> None: """Client-state Vars are reactive and must trigger boundary memoization. A ``client_state`` Var contributes its ``useState``/``useId`` hooks via ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var walk must catch the hooks-only case so client-state-driven content - inside a snapshot boundary lands in the memo body. + inside a snapshot boundary lands in the memo body. Both global and + page-local ``ClientStateVar`` Vars must drive the same wrapping. """ - from reflex_components_core.el.elements.metadata import Title - from reflex.experimental.client_state import ClientStateVar - cs_var = ClientStateVar.create("titletest", default="hi", global_ref=False) + cs_var = ClientStateVar.create("titletest", default="hi", global_ref=global_ref) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( "Client-state-driven title content must memoize. Got: " f"{list(ctx.memoize_wrappers)}" ) - page_output = page_ctx.output_code or "" + page_output = page_ctx.output_code + assert page_output is not None assert "useState" not in page_output, ( "Client-state hooks should be inside the memo body, not the page.\n" f"Page output snippet: {page_output[:2000]}" @@ -1773,9 +1774,11 @@ def test_hooks_only_var_data_descendant_inside_snapshot_boundary_is_memoized() - "Hooks-only VarData should be emitted in the memo body.\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert "useHookOnly" not in (page_ctx.output_code or ""), ( + page_output = page_ctx.output_code + assert page_output is not None + assert "useHookOnly" not in page_output, ( "Hooks-only VarData leaked into the page module.\n" - f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + f"Page output snippet: {page_output[:2000]}" ) @@ -1814,19 +1817,17 @@ def add_hooks(self) -> list[str]: "add_hooks output should be emitted in the memo body.\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert "useHookOnlyChild" not in (page_ctx.output_code or ""), ( + page_output = page_ctx.output_code + assert page_output is not None + assert "useHookOnlyChild" not in page_output, ( "add_hooks output leaked into the page module.\n" - f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + f"Page output snippet: {page_output[:2000]}" ) -def _restricted_stateful_attr_factories() -> list: - from reflex_components_core.el.elements.forms import BaseInput, Textarea - from reflex_components_core.el.elements.media import SvgStyle - from reflex_components_core.el.elements.metadata import Base, Link, Meta - from reflex_components_core.el.elements.scripts import Script - - return [ +@pytest.mark.parametrize( + "factory", + [ pytest.param(lambda: Meta.create(content=STATE_VAR), id="meta_content"), pytest.param(lambda: Base.create(href=STATE_VAR), id="base_href"), pytest.param(lambda: Link.create(href=STATE_VAR), id="link_href"), @@ -1834,10 +1835,8 @@ def _restricted_stateful_attr_factories() -> list: pytest.param(lambda: BaseInput.create(value=STATE_VAR), id="input_value"), pytest.param(lambda: Textarea.create(value=STATE_VAR), id="textarea_value"), pytest.param(lambda: SvgStyle.create(media=STATE_VAR), id="svg_style_media"), - ] - - -@pytest.mark.parametrize("factory", _restricted_stateful_attr_factories()) + ], +) def test_restricted_content_element_with_stateful_attribute_uses_snapshot( factory: Callable[[], Component], ) -> None: @@ -1849,9 +1848,11 @@ def test_restricted_content_element_with_stateful_attribute_uses_snapshot( f"Expected one snapshot wrapper for a stateful restricted attr, got: " f"{list(ctx.memoize_wrappers)}" ) - assert "useTestState" not in (page_ctx.output_code or ""), ( + page_output = page_ctx.output_code + assert page_output is not None + assert "useTestState" not in page_output, ( "Reactive hook marker for restricted attr should not leak to page output.\n" - f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + f"Page output snippet: {page_output[:2000]}" ) assert "useTestState" in memo_code, ( "Reactive hook marker for restricted attr should live in the memo body.\n" @@ -1906,9 +1907,11 @@ def test_real_recursive_false_components_with_stateful_descendants_snapshot_wrap assert type(component).__name__.lower() in wrapper_tag.lower(), ( f"Wrapper {wrapper_tag!r} should be derived from {type(component).__name__}." ) - assert "useContext(StateContexts" not in (page_ctx.output_code or ""), ( + page_output = page_ctx.output_code + assert page_output is not None + assert "useContext(StateContexts" not in page_output, ( "Stateful descendant under a real snapshot boundary leaked to page output.\n" - f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + f"Page output snippet: {page_output[:2000]}" ) @@ -1923,9 +1926,11 @@ def test_restricted_content_element_with_id_and_stateful_child_still_memoizes() assert len(ctx.memoize_wrappers) == 1, ( f"Stateful title with id should still memoize, got: {list(ctx.memoize_wrappers)}" ) - assert "ref_stateful_title" not in (page_ctx.output_code or ""), ( + page_output = page_ctx.output_code + assert page_output is not None + assert "ref_stateful_title" not in page_output, ( "The title ref should move with the snapshot body, not stay on the page.\n" - f"Page output snippet: {(page_ctx.output_code or '')[:2000]}" + f"Page output snippet: {page_output[:2000]}" ) assert "ref_stateful_title" in memo_code, ( "The title ref should be emitted inside the snapshot memo body.\n" @@ -1964,6 +1969,8 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: f"Per-wrapper file invariant broken. wrappers={sorted(ctx.memoize_wrappers)} " f"files={sorted(component_module_names)}" ) - assert len(ctx.memoize_wrappers) >= 2, ( - "Test should exercise multi-wrapper case to be meaningful." + assert len(ctx.memoize_wrappers) == 3, ( + "Test should exercise the multi-wrapper case: one passthrough wrapper " + "for Plain, one for WithProp, and one snapshot wrapper for the " + f"LeafComponent boundary. Got: {sorted(ctx.memoize_wrappers)}" ) From c287af19dbdad48fd1be540a765df9db75a26890 Mon Sep 17 00:00:00 2001 From: Masen Furer <m_github@0x26.net> Date: Wed, 29 Apr 2026 16:07:11 -0700 Subject: [PATCH 54/59] lifespan: ignore uninitialized state_manager during lifespan cleanup --- reflex/app_mixins/lifespan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/app_mixins/lifespan.py b/reflex/app_mixins/lifespan.py index bac00517bb0..13642e7e458 100644 --- a/reflex/app_mixins/lifespan.py +++ b/reflex/app_mixins/lifespan.py @@ -134,7 +134,7 @@ async def _run_lifespan_tasks(self, app: Starlette): # Flush any pending writes from the state manager. try: state_manager = self.state_manager # pyright: ignore[reportAttributeAccessIssue] - except AttributeError: + except (AttributeError, ValueError): pass else: await state_manager.close() From 3f08dd781246e24a975bd26c193cb100874c3036 Mon Sep 17 00:00:00 2001 From: Masen Furer <m_github@0x26.net> Date: Wed, 29 Apr 2026 18:04:02 -0700 Subject: [PATCH 55/59] Do not auto-memoize components with imports-only VarData To pass `_should_memoize`, the component/var must contain state or hooks --- reflex/compiler/plugins/memoize.py | 26 +++++++++++++++++--- tests/units/compiler/test_memoize_plugin.py | 27 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 52c190c313e..b596b147a79 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -174,9 +174,19 @@ def _should_memoize(component: Component) -> bool: if component._memoization_mode.disposition == MemoizationDisposition.NEVER: return False - if isinstance(component, Bare) and component.contents._get_all_var_data(): - # A stateful value will be wrapped in a separate component. - return True + if isinstance(component, Bare): + # A stateful value will be wrapped in a separate component. Match the + # per-Var predicate used by ``_subtree_has_reactive_data`` so a Bare + # whose Var carries only imports (no state/hooks) is not memoized. + contents_var_data = component.contents._get_all_var_data() + if contents_var_data is not None: + if contents_var_data.state or contents_var_data.hooks: + return True + for embedded in contents_var_data.components: + if isinstance(embedded, Component) and _subtree_has_reactive_data( + embedded + ): + return True # Cond and Match render conditional branch JSX from their own props rather # than from a tag, so they have no `tag` but still must be considered. if component.tag is None and not isinstance(component, (Cond, Match)): @@ -185,9 +195,17 @@ def _should_memoize(component: Component) -> bool: return True # Direct Vars only (component's own props, style, class_name, id, etc.). + # Match the per-Var predicate used by ``_subtree_has_reactive_data`` + # var_data carrying only imports is not reactive. for prop_var in component._get_vars(include_children=False): - if prop_var._get_all_var_data(): + var_data = prop_var._get_all_var_data() + if var_data is None: + continue + if var_data.state or var_data.hooks: return True + for embedded in var_data.components: + if isinstance(embedded, Component) and _subtree_has_reactive_data(embedded): + return True if strategy is MemoizationStrategy.SNAPSHOT and not is_snapshot_boundary(component): return True diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 56308005e42..18fc3b3e5dc 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -123,6 +123,33 @@ def test_should_memoize_state_var_in_child_cond() -> None: assert _should_memoize(comp) +def test_should_not_memoize_prop_var_with_imports_only_var_data() -> None: + """Prop Vars carrying only imports (no state/hooks) must not trigger memoize. + + Regression: a ``class_name`` produced by the ``cn`` helper (clsx-for-tailwind) + has VarData with non-empty ``imports`` but empty ``state`` and ``hooks``; + snapshot-boundary elements like ``<textarea>`` were being wrapped in memo + purely because of that helper import. + """ + from reflex_base.utils.imports import ImportVar + + import_only_var = LiteralVar.create("static-class")._replace( + merge_var_data=VarData( + imports={"clsx-for-tailwind": [ImportVar(tag="cn")]}, + ) + ) + comp = WithProp.create(label=import_only_var) + assert not _should_memoize(comp) + # Snapshot-boundary form of the same: a Textarea whose only stateful-looking + # signal is an import-bearing class_name should not be memoized either. + boundary = Textarea.create(class_name=import_only_var, name="x") + assert not _should_memoize(boundary) + # And the Bare-contents short-circuit must use the same predicate: a Bare + # wrapping a Var with import-only var_data must not be memoized. + bare = Bare.create(import_only_var) + assert not _should_memoize(bare) + + def test_should_not_memoize_when_disposition_never() -> None: """``MemoizationDisposition.NEVER`` overrides heuristic eligibility.""" comp = Plain.create(STATE_VAR) From b73b8b464a8d0747b8b3622ebdd09a6cbdef423c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <farhanalirazaazeemi@gmail.com> Date: Thu, 30 Apr 2026 23:21:12 +0500 Subject: [PATCH 56/59] fix: render dynamic components as React elements via createElement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evalReactComponent returned the module's default export (a component function) directly, so the runtime tried to render the function as a child rather than instantiating it — event handlers on the dynamic subtree never wired up. Wrap the default export in React.createElement so the dynamic component mounts as a real element. Adds an integration regression for a counter-style dynamic component that exercises event handlers on the dynamically rendered subtree. --- .../reflex_base/.templates/web/utils/state.js | 2 +- tests/integration/test_dynamic_components.py | 54 ++++++++- .../test_dynamic_components_codegen.py | 107 ++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 tests/units/compiler/test_dynamic_components_codegen.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index fd2b5e5741b..36fd4e6f623 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -156,7 +156,7 @@ export const evalReactComponent = async (component) => { const encodedJs = encodeURIComponent(component); const dataUri = "data:text/javascript;charset=utf-8," + encodedJs; const module = await eval(`import(dataUri)`); - return module.default; + return window.React.createElement(module.default); }; /** diff --git a/tests/integration/test_dynamic_components.py b/tests/integration/test_dynamic_components.py index cbd60fc227a..3de8eef034f 100644 --- a/tests/integration/test_dynamic_components.py +++ b/tests/integration/test_dynamic_components.py @@ -17,6 +17,7 @@ def DynamicComponents(): class DynamicComponentsState(rx.State): value: int = 10 + count: int = 0 button: rx.Component = rx.button( "Click me", @@ -34,6 +35,15 @@ def got_clicked(self): }, ) + @rx.event + def set_count(self, count: int): + """Set the counter value. + + Args: + count: The new counter value. + """ + self.count = count + @rx.var def client_token_component(self) -> rx.Component: return rx.vstack( @@ -53,6 +63,27 @@ def client_token_component(self) -> rx.Component: ), ) + @rx.var + def counter_component(self) -> rx.Component: + """Get a dynamic counter component with event handlers. + + Returns: + The dynamic counter component. + """ + return rx.hstack( + rx.button( + "-", + id="decrement", + on_click=DynamicComponentsState.set_count(self.count - 1), + ), + rx.text(self.count, id="count"), + rx.button( + "+", + id="increment", + on_click=DynamicComponentsState.set_count(self.count + 1), + ), + ) + app = rx.App() def factorial(n: int) -> int: @@ -65,6 +96,7 @@ def index(): return rx.vstack( DynamicComponentsState.client_token_component, DynamicComponentsState.button, + DynamicComponentsState.counter_component, rx.text( DynamicComponentsState._evaluate( lambda state: factorial(state.value), of_type=int @@ -135,12 +167,28 @@ def test_dynamic_components(driver, dynamic_components: AppHarness): assert update_button update_button.click() - assert ( - dynamic_components.poll_for_content(button, exp_not_equal="Click me") - == "Clicked" + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "button").text == "Clicked" ) factorial = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "factorial") ) assert factorial.text == "3628800" + + count = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count") + ) + assert count.text == "0" + + increment = driver.find_element(By.ID, "increment") + increment.click() + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count").text == "1" + ) + + decrement = driver.find_element(By.ID, "decrement") + decrement.click() + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count").text == "0" + ) diff --git a/tests/units/compiler/test_dynamic_components_codegen.py b/tests/units/compiler/test_dynamic_components_codegen.py new file mode 100644 index 00000000000..fe859984346 --- /dev/null +++ b/tests/units/compiler/test_dynamic_components_codegen.py @@ -0,0 +1,107 @@ +"""Code generation tests for dynamic components.""" + +from pathlib import Path + +from reflex_base.utils import serializers + +import reflex as rx +from reflex.state import State + +STATE_JS_TEMPLATE = ( + Path(__file__).parents[3] + / "packages/reflex-base/src/reflex_base/.templates/web/utils/state.js" +) + + +def test_dynamic_component_codegen_wires_event_handlers() -> None: + """Dynamic component codegen should preserve backend event handlers.""" + state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + component = rx.el.div( + rx.el.button("hydrate", on_click=State.set_is_hydrated(True)), + rx.el.span(state.is_hydrated), + rx.el.button("unhydrate", on_click=State.set_is_hydrated(False)), + ) + code = serializers.serialize(component) + + assert isinstance(code, str) + assert code.startswith("//__reflex_evaluate") + assert "const {Fragment,useContext,useEffect}" in code + assert "const {EventLoopContext} = window['__reflex'][\"$/utils/context\"]" in code + assert ( + "const {ReflexEvent,applyEventActions} = window['__reflex'][\"$/utils/state\"]" + in code + ) + assert "const [addEvents, connectErrors] = useContext(EventLoopContext);" in code + assert code.count("onClick:") == 2 + assert code.count("addEvents(") == 2 + assert code.count("ReflexEvent(") == 2 + assert ( + 'ReflexEvent("reflex___state____state.set_is_hydrated", ' + '({ ["value"] : true }), ({ }))' + ) in code + assert ( + 'ReflexEvent("reflex___state____state.set_is_hydrated", ' + '({ ["value"] : false }), ({ }))' + ) in code + + +def test_dynamic_component_codegen_wires_state_var_counter_events() -> None: + """Dynamic component codegen should preserve stateful counter event handlers.""" + + class DynamicCounterCodegenState(rx.State): + count: int = 0 + + @rx.event + def set_count(self, count: int): + """Set the counter value. + + Args: + count: The new counter value. + """ + self.count = count + + @rx.var + def counter_ui(self) -> rx.Component: + """Get a dynamic counter component. + + Returns: + The dynamic counter component. + """ + return rx.hstack( + rx.button( + "-", + on_click=DynamicCounterCodegenState.set_count(self.count - 1), + ), + rx.text(self.count, size="9"), + rx.button( + "+", + on_click=DynamicCounterCodegenState.set_count(self.count + 1), + ), + spacing="5", + justify="center", + ) + + state = DynamicCounterCodegenState(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + code = serializers.serialize(state.counter_ui) + + assert isinstance(code, str) + assert code.startswith("//__reflex_evaluate") + assert "RadixThemesFlex" in code + assert "RadixThemesButton" in code + assert "RadixThemesText" in code + assert 'justify:"center"' in code + assert 'gap:"5"' in code + assert "const {Fragment,useContext,useEffect}" in code + assert "const {EventLoopContext} = window['__reflex'][\"$/utils/context\"]" in code + assert ( + "const {ReflexEvent,applyEventActions} = window['__reflex'][\"$/utils/state\"]" + in code + ) + assert "const [addEvents, connectErrors] = useContext(EventLoopContext);" in code + assert code.count("onClick:") == 2 + assert code.count("addEvents(") == 2 + assert code.count("ReflexEvent(") == 2 + assert code.count(".set_count") == 2 + assert '({ ["count"] : -1 }), ({ })' in code + assert '({ ["count"] : 1 }), ({ })' in code + assert 'jsx(RadixThemesText, ({as:"p",size:"9"}), 0)' in code From 4d74b68f424e0109a8fb8aad36e862764cc196d1 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <farhanalirazaazeemi@gmail.com> Date: Thu, 30 Apr 2026 20:06:36 +0000 Subject: [PATCH 57/59] Revert state.js dynamic component createElement wrap and integration test The codegen-level bug those changes were meant to address (dynamic components emitting JSX without onClick / EventLoopContext wiring) was already fixed by 3f08dd78 ("Do not auto-memoize components with imports-only VarData"). The state.js change was a no-op for the reported symptom and freezes the dynamic element with no props at eval time, which breaks any consumer expecting a renderable component. Drop the runtime change and the integration test additions; keep the codegen unit tests as a regression guard for the memoize fix. --- .../reflex_base/.templates/web/utils/state.js | 2 +- tests/integration/test_dynamic_components.py | 54 ++----------------- 2 files changed, 4 insertions(+), 52 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 36fd4e6f623..fd2b5e5741b 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -156,7 +156,7 @@ export const evalReactComponent = async (component) => { const encodedJs = encodeURIComponent(component); const dataUri = "data:text/javascript;charset=utf-8," + encodedJs; const module = await eval(`import(dataUri)`); - return window.React.createElement(module.default); + return module.default; }; /** diff --git a/tests/integration/test_dynamic_components.py b/tests/integration/test_dynamic_components.py index 3de8eef034f..cbd60fc227a 100644 --- a/tests/integration/test_dynamic_components.py +++ b/tests/integration/test_dynamic_components.py @@ -17,7 +17,6 @@ def DynamicComponents(): class DynamicComponentsState(rx.State): value: int = 10 - count: int = 0 button: rx.Component = rx.button( "Click me", @@ -35,15 +34,6 @@ def got_clicked(self): }, ) - @rx.event - def set_count(self, count: int): - """Set the counter value. - - Args: - count: The new counter value. - """ - self.count = count - @rx.var def client_token_component(self) -> rx.Component: return rx.vstack( @@ -63,27 +53,6 @@ def client_token_component(self) -> rx.Component: ), ) - @rx.var - def counter_component(self) -> rx.Component: - """Get a dynamic counter component with event handlers. - - Returns: - The dynamic counter component. - """ - return rx.hstack( - rx.button( - "-", - id="decrement", - on_click=DynamicComponentsState.set_count(self.count - 1), - ), - rx.text(self.count, id="count"), - rx.button( - "+", - id="increment", - on_click=DynamicComponentsState.set_count(self.count + 1), - ), - ) - app = rx.App() def factorial(n: int) -> int: @@ -96,7 +65,6 @@ def index(): return rx.vstack( DynamicComponentsState.client_token_component, DynamicComponentsState.button, - DynamicComponentsState.counter_component, rx.text( DynamicComponentsState._evaluate( lambda state: factorial(state.value), of_type=int @@ -167,28 +135,12 @@ def test_dynamic_components(driver, dynamic_components: AppHarness): assert update_button update_button.click() - assert AppHarness.poll_for_or_raise_timeout( - lambda: driver.find_element(By.ID, "button").text == "Clicked" + assert ( + dynamic_components.poll_for_content(button, exp_not_equal="Click me") + == "Clicked" ) factorial = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "factorial") ) assert factorial.text == "3628800" - - count = AppHarness.poll_for_or_raise_timeout( - lambda: driver.find_element(By.ID, "count") - ) - assert count.text == "0" - - increment = driver.find_element(By.ID, "increment") - increment.click() - assert AppHarness.poll_for_or_raise_timeout( - lambda: driver.find_element(By.ID, "count").text == "1" - ) - - decrement = driver.find_element(By.ID, "decrement") - decrement.click() - assert AppHarness.poll_for_or_raise_timeout( - lambda: driver.find_element(By.ID, "count").text == "0" - ) From 9a744f2cdd2f61c1f9f3a58bba18c51cd1e20dab Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <farhanalirazaazeemi@gmail.com> Date: Thu, 30 Apr 2026 20:34:45 +0000 Subject: [PATCH 58/59] Restore evalReactComponent createElement wrap and integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useState's setter treats a function argument as an updater (setState(prev => newValue)), so passing module.default (a function) to set_xdvxrcsn invokes MySSRComponent inside basicStateReducer, where its useContext / useRef hooks run outside a render cycle and trip "Rendered more hooks than during the previous render." Wrapping in React.createElement returns an element object — useState stores it directly without invoking it as an updater, and React renders the element normally on the next pass, running hooks in a proper render context. Reverts 4d74b68f. Keeps the codegen unit tests (independently useful as a regression guard for the auto-memoize fix in 3f08dd78). --- .../reflex_base/.templates/web/utils/state.js | 2 +- tests/integration/test_dynamic_components.py | 54 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index fd2b5e5741b..36fd4e6f623 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -156,7 +156,7 @@ export const evalReactComponent = async (component) => { const encodedJs = encodeURIComponent(component); const dataUri = "data:text/javascript;charset=utf-8," + encodedJs; const module = await eval(`import(dataUri)`); - return module.default; + return window.React.createElement(module.default); }; /** diff --git a/tests/integration/test_dynamic_components.py b/tests/integration/test_dynamic_components.py index cbd60fc227a..3de8eef034f 100644 --- a/tests/integration/test_dynamic_components.py +++ b/tests/integration/test_dynamic_components.py @@ -17,6 +17,7 @@ def DynamicComponents(): class DynamicComponentsState(rx.State): value: int = 10 + count: int = 0 button: rx.Component = rx.button( "Click me", @@ -34,6 +35,15 @@ def got_clicked(self): }, ) + @rx.event + def set_count(self, count: int): + """Set the counter value. + + Args: + count: The new counter value. + """ + self.count = count + @rx.var def client_token_component(self) -> rx.Component: return rx.vstack( @@ -53,6 +63,27 @@ def client_token_component(self) -> rx.Component: ), ) + @rx.var + def counter_component(self) -> rx.Component: + """Get a dynamic counter component with event handlers. + + Returns: + The dynamic counter component. + """ + return rx.hstack( + rx.button( + "-", + id="decrement", + on_click=DynamicComponentsState.set_count(self.count - 1), + ), + rx.text(self.count, id="count"), + rx.button( + "+", + id="increment", + on_click=DynamicComponentsState.set_count(self.count + 1), + ), + ) + app = rx.App() def factorial(n: int) -> int: @@ -65,6 +96,7 @@ def index(): return rx.vstack( DynamicComponentsState.client_token_component, DynamicComponentsState.button, + DynamicComponentsState.counter_component, rx.text( DynamicComponentsState._evaluate( lambda state: factorial(state.value), of_type=int @@ -135,12 +167,28 @@ def test_dynamic_components(driver, dynamic_components: AppHarness): assert update_button update_button.click() - assert ( - dynamic_components.poll_for_content(button, exp_not_equal="Click me") - == "Clicked" + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "button").text == "Clicked" ) factorial = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "factorial") ) assert factorial.text == "3628800" + + count = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count") + ) + assert count.text == "0" + + increment = driver.find_element(By.ID, "increment") + increment.click() + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count").text == "1" + ) + + decrement = driver.find_element(By.ID, "decrement") + decrement.click() + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "count").text == "0" + ) From 67a450dad6076e4a3b8344e0b9ec5717ae59f0de Mon Sep 17 00:00:00 2001 From: Masen Furer <m_github@0x26.net> Date: Thu, 30 Apr 2026 13:49:51 -0700 Subject: [PATCH 59/59] Move `createElement` to dynamic component hook code Allows better modularity in `evalReactComponent` to not assume the callers usage of the returned value at that time. --- .../reflex-base/src/reflex_base/.templates/web/utils/state.js | 2 +- packages/reflex-base/src/reflex_base/components/dynamic.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 36fd4e6f623..fd2b5e5741b 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -156,7 +156,7 @@ export const evalReactComponent = async (component) => { const encodedJs = encodeURIComponent(component); const dataUri = "data:text/javascript;charset=utf-8," + encodedJs; const module = await eval(`import(dataUri)`); - return window.React.createElement(module.default); + return module.default; }; /** diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 6fdfc911baa..a668bd341fc 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -196,6 +196,7 @@ def evaluate_component(js_string: Var[str]) -> Var[Component]: imports.ImportVar(tag="evalReactComponent"), ], "react": [ + imports.ImportVar(tag="createElement"), imports.ImportVar(tag="useState"), imports.ImportVar(tag="useEffect"), ], @@ -207,7 +208,7 @@ def evaluate_component(js_string: Var[str]) -> Var[Component]: f"evalReactComponent({js_string!s})" ".then((component) => {" "if (isMounted) {" - f"set_{unique_var_name}(component);" + f"set_{unique_var_name}(() => createElement(component));" "}" "});" "return () => {"