diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 3c525e99f4e..597683aa071 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -200,7 +200,7 @@ def app_root_template( return f""" {imports_str} {dynamic_imports_str} -import {{ EventLoopProvider, StateProvider, defaultColorMode }} from "$/utils/context"; +import {{ defaultColorMode }} from "$/utils/context"; import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; @@ -225,11 +225,7 @@ def app_root_template( return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, - jsx(StateProvider, {{}}, - jsx(EventLoopProvider, {{}}, - jsx(AppWrap, {{}}, children) - ) - ) + jsx(AppWrap, {{}}, children) ) ); }} @@ -364,6 +360,24 @@ def context_template( export const isDevMode = {json.dumps(is_dev_mode)}; +// Module-level event dispatchers populated by ``EventLoopProvider`` on each +// render. Components reach addEvents/connectErrors via this import instead of +// hoisting ``useContext(EventLoopContext)`` so JSX literals (e.g. +// ``ErrorBoundary.onError``) constructed in any JS scope can dispatch events +// without depending on lexical hook hoisting. +let _addEventsImpl = (events, args, event_actions) => {{ + console.warn("addEvents called before EventLoopProvider mounted", events); +}}; +let _connectErrorsImpl = []; + +export function addEvents(events, args, event_actions) {{ + return _addEventsImpl(events, args, event_actions); +}} + +export function getConnectErrors() {{ + return _connectErrorsImpl; +}} + export function UploadFilesProvider({{ children }}) {{ const [filesById, setFilesById] = useState({{}}) refs["__clear_selected_files"] = (id) => setFilesById(filesById => {{ @@ -394,14 +408,19 @@ def context_template( export function EventLoopProvider({{ children }}) {{ const dispatch = useContext(DispatchContext) - const [addEvents, connectErrors] = useEventLoop( + const [addEventsLocal, connectErrors] = useEventLoop( dispatch, initialEvents, clientStorage, ) + // Populate the module-level dispatchers so JSX literals constructed + // outside the React-tree path (e.g. ``ErrorBoundary.onError``) can call + // ``addEvents`` without needing the events hook hoisted in their scope. + _addEventsImpl = addEventsLocal; + _connectErrorsImpl = connectErrors; return createElement( EventLoopContext.Provider, - {{ value: [addEvents, connectErrors] }}, + {{ value: [addEventsLocal, connectErrors] }}, children ); }} diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 79b562ddc78..196955f7044 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1879,14 +1879,15 @@ def _get_vars_hooks(self) -> dict[str, VarData | None]: def _get_events_hooks(self) -> dict[str, VarData | None]: """Get the hooks required by events referenced in this component. + ``addEvents`` is reached via the module-level import in + ``Imports.EVENTS``, so no in-scope hook is needed for events. + State/event-loop providers ride along on the event invocation's + ``VarData.app_wraps`` and via :meth:`_get_event_app_wraps`. + Returns: - The hooks for the events. + An empty dict. """ - return ( - {Hooks.EVENTS: VarData(position=Hooks.HookPosition.INTERNAL)} - if self.event_triggers - else {} - ) + return {} def _get_hooks_internal(self) -> dict[str, VarData | None]: """Get the React hooks for this component managed by the framework. @@ -2041,6 +2042,30 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]: """ return {} + def _get_event_app_wraps(self) -> dict[tuple[int, str], Component]: + """Return state/event-loop providers required by event triggers. + + Kept separate from :meth:`_get_app_wrap_components` so subclass + overrides of that method don't inadvertently strip these out — + the providers must be in the React tree for ``StateContexts``, + ``EventLoopContext``, and the websocket plumbing to stay alive + even though ``addEvents`` itself is reached via module-level + import rather than a hook closure. + + Returns: + The state/event-loop provider entries (empty if no event + triggers are bound). + """ + if not self.event_triggers: + return {} + # Lazy import: state_context imports from this module. + from reflex_base.components.state_context import get_event_app_wraps + + return { + (priority, provider.tag or type(provider).__name__): provider + for priority, provider in get_event_app_wraps() + } + def _get_all_app_wrap_components( self, *, ignore_ids: set[int] | None = None ) -> dict[tuple[int, str], Component]: diff --git a/packages/reflex-base/src/reflex_base/components/state_context.py b/packages/reflex-base/src/reflex_base/components/state_context.py new file mode 100644 index 00000000000..4bccc1c88cd --- /dev/null +++ b/packages/reflex-base/src/reflex_base/components/state_context.py @@ -0,0 +1,59 @@ +"""App-wrap components mounting the state and event-loop React providers. + +These wrap children in the ``StateProvider`` / ``EventLoopProvider`` JS +functions emitted into ``utils/context.js`` by ``compile_contexts``. They are +attached to the VarData returned by :meth:`reflex_base.vars.base.VarData.from_state` +so the compiler picks them up through the generic Var-driven app-wrap pipeline, +rather than the JS Layout template hard-coding them around every app. +""" + +from __future__ import annotations + +from reflex_base.components.component import Component +from reflex_base.constants import Dirs +from reflex_base.constants.compiler import Hooks +from reflex_base.vars.base import VarData + + +class StateContextProvider(Component): + """App wrap that mounts the React state-context provider around children.""" + + library = f"$/{Dirs.CONTEXTS_PATH}" + tag = "StateProvider" + + +class EventLoopContextProvider(Component): + """App wrap that mounts the websocket event-loop provider around children.""" + + library = f"$/{Dirs.CONTEXTS_PATH}" + tag = "EventLoopProvider" + + +def get_event_app_wraps() -> tuple[tuple[int, Component], ...]: + """Return state/event-loop providers required when events are dispatched. + + ``StateProvider`` (100) wraps further out than ``EventLoopProvider`` + (90) because the latter reads ``DispatchContext`` from the former. + Providers are constructed fresh per call — module-level caching + breaks because ``copy.deepcopy`` (used when assembling the app-root + chain) carries ``_cached_render_result`` across compile runs. + + Returns: + ``(priority, provider)`` entries deduped by the compiler. + """ + return ( + (100, StateContextProvider.create()), + (90, EventLoopContextProvider.create()), + ) + + +def get_events_hooks_var_data() -> VarData: + """Build the VarData advertising the state/event-loop app wraps. + + Returns: + A new VarData carrying both providers as app_wraps. + """ + return VarData( + position=Hooks.HookPosition.INTERNAL, + app_wraps=get_event_app_wraps(), + ) diff --git a/packages/reflex-base/src/reflex_base/constants/compiler.py b/packages/reflex-base/src/reflex_base/constants/compiler.py index 8113f522484..ec68fe546dc 100644 --- a/packages/reflex-base/src/reflex_base/constants/compiler.py +++ b/packages/reflex-base/src/reflex_base/constants/compiler.py @@ -127,20 +127,34 @@ class CompileContext(str, Enum): class Imports(SimpleNamespace): """Common sets of import vars.""" + # ``addEvents`` is a module-level callable populated by + # ``EventLoopProvider``; importing it sidesteps the lexical-scope + # constraint a ``useContext(EventLoopContext)`` hoist would impose. EVENTS = { - "react": [ImportVar(tag="useContext")], - f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")], + f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag=CompileVars.ADD_EVENTS)], f"$/{Dirs.STATE_PATH}": [ ImportVar(tag=CompileVars.TO_EVENT), ImportVar(tag=CompileVars.APPLY_EVENT_ACTIONS), ], } + # ``connectErrors`` is reactive — it drives connection-banner + # re-renders — so its consumers still go through ``useContext``. + CONNECT_ERRORS = { + "react": [ImportVar(tag="useContext")], + f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")], + } + class Hooks(SimpleNamespace): """Common sets of hook declarations.""" + # Kept for legacy callers that still key off this string; the + # compiler no longer auto-hoists it. EVENTS = f"const [{CompileVars.ADD_EVENTS}, {CompileVars.CONNECT_ERROR}] = useContext(EventLoopContext);" + CONNECT_ERRORS = ( + f"const {CompileVars.CONNECT_ERROR} = useContext(EventLoopContext)[1];" + ) class HookPosition(enum.Enum): """The position of the hook in the component.""" diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 8762e694d18..730a3672f66 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -1055,14 +1055,14 @@ def _as_event_spec( """ from reflex_components_core.core.upload import ( DEFAULT_UPLOAD_ID, - upload_files_context_var_data, + get_upload_files_context_var_data, ) upload_id = self.upload_id if self.upload_id is not None else DEFAULT_UPLOAD_ID upload_files_var = Var( _js_expr="filesById", _var_type=dict[str, Any], - _var_data=VarData.merge(upload_files_context_var_data), + _var_data=VarData.merge(get_upload_files_context_var_data()), ).to(ObjectVar)[LiteralVar.create(upload_id)] spec_args = [ ( @@ -2335,11 +2335,14 @@ def create( arg_def_expr = Var(_js_expr="args") if value.invocation is None: + # Lazy import: state_context → component → event (this module). + from reflex_base.components.state_context import get_event_app_wraps + invocation = FunctionStringVar.create( CompileVars.ADD_EVENTS, _var_data=VarData( imports=Imports.EVENTS, - hooks={Hooks.EVENTS: None}, + app_wraps=get_event_app_wraps(), ), ) else: @@ -2380,11 +2383,14 @@ def create( _js_expr=f"{{{''.join(f'{statement};' for statement in statements)}}}", ) if value.event_actions: + # Lazy import: state_context → component → event (this module). + from reflex_base.components.state_context import get_event_app_wraps + apply_event_actions = FunctionStringVar.create( CompileVars.APPLY_EVENT_ACTIONS, _var_data=VarData( imports=Imports.EVENTS, - hooks={Hooks.EVENTS: None}, + app_wraps=get_event_app_wraps(), ), ) return_expr = apply_event_actions.call( diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 4034b69aa3c..c6dce1b6f26 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -141,6 +141,12 @@ class VarData: # Components that are part of this var components: tuple[BaseComponent, ...] = dataclasses.field(default_factory=tuple) + # App-level wrapper components this var requires when used (priority, component). + # Higher priority wraps further out, matching Component._get_app_wrap_components semantics. + app_wraps: tuple[tuple[int, BaseComponent], ...] = dataclasses.field( + default_factory=tuple + ) + def __init__( self, state: str = "", @@ -150,6 +156,7 @@ def __init__( deps: list[Var] | None = None, position: Hooks.HookPosition | None = None, components: Iterable[BaseComponent] | None = None, + app_wraps: Iterable[tuple[int, BaseComponent]] | None = None, ): """Initialize the var data. @@ -161,6 +168,7 @@ def __init__( deps: Dependencies of the var for useCallback. position: Position of the hook in the component. components: Components that are part of this var. + app_wraps: App-level wrapper components this var requires when used. """ if isinstance(hooks, str): hooks = [hooks] @@ -176,6 +184,7 @@ def __init__( object.__setattr__(self, "deps", tuple(deps or [])) object.__setattr__(self, "position", position or None) object.__setattr__(self, "components", tuple(components or [])) + object.__setattr__(self, "app_wraps", tuple(app_wraps or [])) if hooks and any(hooks.values()): # Merge our dependencies first, so they can be referenced. @@ -188,6 +197,7 @@ def __init__( object.__setattr__(self, "deps", merged_var_data.deps) object.__setattr__(self, "position", merged_var_data.position) object.__setattr__(self, "components", merged_var_data.components) + object.__setattr__(self, "app_wraps", merged_var_data.app_wraps) def old_school_imports(self) -> ImportDict: """Return the imports as a mutable dict. @@ -259,6 +269,16 @@ def merge(*all: VarData | None) -> VarData | None: component for var_data in all_var_datas for component in var_data.components ) + app_wraps_seen: set[tuple[int, str]] = set() + app_wraps_list: list[tuple[int, BaseComponent]] = [] + for var_data in all_var_datas: + for priority, wrapper in var_data.app_wraps: + key = (priority, wrapper.tag or type(wrapper).__name__) + if key in app_wraps_seen: + continue + app_wraps_seen.add(key) + app_wraps_list.append((priority, wrapper)) + return VarData( state=state, field_name=field_name, @@ -267,6 +287,7 @@ def merge(*all: VarData | None) -> VarData | None: deps=deps, position=position, components=components, + app_wraps=tuple(app_wraps_list), ) def __bool__(self) -> bool: @@ -283,8 +304,59 @@ def __bool__(self) -> bool: or self.deps or self.position or self.components + or self.app_wraps ) + def _identity_key(self) -> tuple: + """Return a hashable key for ``__eq__`` and ``__hash__``. + + ``components`` and ``app_wraps`` hold ``BaseComponent`` instances whose + ``__eq__`` override drops the default hash. Use component identity for + embedded components because they can contribute hooks/imports, and use + the compiler's app-wrap registry key for wrappers so fresh provider + instances with the same role still compare equal. + + Returns: + A hashable tuple uniquely identifying this VarData. + """ + return ( + self.state, + self.field_name, + self.imports, + self.hooks, + self.deps, + self.position, + tuple(id(component) for component in self.components), + tuple( + ( + priority, + component.tag or type(component).__name__, + ) + for priority, component in self.app_wraps + ), + ) + + def __eq__(self, other: object) -> bool: + """Compare two VarData by render-time identity. + + Args: + other: The value to compare against. + + Returns: + True if ``other`` is a VarData with matching render-time fields. + """ + if not isinstance(other, VarData): + return NotImplemented + return self._identity_key() == other._identity_key() + + def __hash__(self) -> int: + """Hash consistent with ``__eq__``. + + Returns: + A hash over render-time fields and hashable component metadata. + """ + return hash(self._identity_key()) + @classmethod def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarData: """Set the state of the var. @@ -296,6 +368,11 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa Returns: The var with the set state. """ + # Lazy imports: state_context imports VarData from this module. + from reflex_base.components.state_context import ( + EventLoopContextProvider, + StateContextProvider, + ) from reflex_base.utils import format state_name = state if isinstance(state, str) else state.get_full_name() @@ -311,6 +388,17 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")], "react": [ImportVar(tag="useContext")], }, + app_wraps=( + # Higher priority wraps further out. ``StateProvider`` must + # enclose ``EventLoopProvider`` because the latter reads + # ``DispatchContext`` (provided by StateProvider) at its top. + # Both must enclose the chain's other wraps so the hooks + # AppWrap hosts (e.g. ``useContext(EventLoopContext)``) see + # them as React-tree ancestors. The compiler dedupes by + # ``(priority, tag)`` so fresh per-call instances are fine. + (100, StateContextProvider.create()), + (90, EventLoopContextProvider.create()), + ), ) @@ -362,7 +450,7 @@ def can_use_in_object_var(cls: GenericType) -> bool: Whether the class can be used in an ObjectVar. """ if types.is_union(cls): - return all(can_use_in_object_var(t) for t in types.get_args(cls)) + return all(can_use_in_object_var(t) for t in get_args(cls)) return ( isinstance(cls, type) and not safe_issubclass(cls, Var) diff --git a/packages/reflex-components-core/src/reflex_components_core/base/app_wrap.py b/packages/reflex-components-core/src/reflex_components_core/base/app_wrap.py index e5a0a491f3a..e9998ec8b7a 100644 --- a/packages/reflex-components-core/src/reflex_components_core/base/app_wrap.py +++ b/packages/reflex-components-core/src/reflex_components_core/base/app_wrap.py @@ -7,7 +7,13 @@ class AppWrap(Fragment): - """Top-level component that wraps the entire app.""" + """Innermost (priority 0) element of the python app-wrap chain. + + Renders as ``jsx(Fragment, {}, children)`` — the chain ends here, with + the route ``children`` JS variable flowing through. Same-priority + siblings (e.g. ``StickyBadge``) get appended via the chain reducer and + sit alongside ``children`` inside this Fragment. + """ @classmethod def create(cls) -> Component: diff --git a/packages/reflex-components-core/src/reflex_components_core/core/banner.py b/packages/reflex-components-core/src/reflex_components_core/core/banner.py index 8d9af0bef76..7174ff1b3d5 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/banner.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/banner.py @@ -22,8 +22,8 @@ from reflex_components_core.el.elements.typography import Div connect_error_var_data: VarData = VarData( - imports=Imports.EVENTS, - hooks={Hooks.EVENTS: None}, + imports=Imports.CONNECT_ERRORS, + hooks={Hooks.CONNECT_ERRORS: None}, ) connect_errors = Var( @@ -173,7 +173,7 @@ def add_hooks(self) -> list[str | Var]: ] return [ - Hooks.EVENTS, + Hooks.CONNECT_ERRORS, *individual_hooks, ] 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 9bbb28de64d..7951ea60abd 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 @@ -13,8 +13,9 @@ field, ) from reflex_base.components.memoize_helpers import get_memoized_event_triggers +from reflex_base.components.state_context import get_event_app_wraps from reflex_base.constants import Dirs -from reflex_base.constants.compiler import Hooks, Imports +from reflex_base.constants.compiler import Imports from reflex_base.environment import environment from reflex_base.event import ( CallableEventSpec, @@ -46,15 +47,29 @@ DEFAULT_UPLOAD_ID: str = "default" -upload_files_context_var_data: VarData = VarData( - imports={ - "react": "useContext", - f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext", - }, - hooks={ - "const [filesById, setFilesById] = useContext(UploadFilesContext);": None, - }, -) + +def get_upload_files_context_var_data() -> VarData: + """Build the VarData for vars reading the upload-files React context. + + Defined as a function (not a module-level value) because it references + ``UploadFilesProvider``, which is declared further down in this module. + Returns a fresh instance per call so render-cache state can't leak + across compile runs via ``copy.deepcopy``. + + Returns: + A new VarData carrying the upload-context import, hook, and the + ``UploadFilesProvider`` app_wrap declaration. + """ + return VarData( + imports={ + "react": "useContext", + f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext", + }, + hooks={ + "const [filesById, setFilesById] = useContext(UploadFilesContext);": None, + }, + app_wraps=((5, UploadFilesProvider.create()),), + ) def upload_file(id_: str | Var[str] = DEFAULT_UPLOAD_ID) -> Var: @@ -81,7 +96,7 @@ def upload_file(id_: str | Var[str] = DEFAULT_UPLOAD_ID) -> Var: _js_expr=var_name, _var_type=EventChain, _var_data=VarData.merge( - upload_files_context_var_data, id_var._get_all_var_data() + get_upload_files_context_var_data(), id_var._get_all_var_data() ), ) @@ -100,7 +115,7 @@ def selected_files(id_: str | Var[str] = DEFAULT_UPLOAD_ID) -> Var: _js_expr=f"(filesById[{id_var!s}] ? filesById[{id_var!s}].map((f) => f.name) : [])", _var_type=list[str], _var_data=VarData.merge( - upload_files_context_var_data, id_var._get_all_var_data() + get_upload_files_context_var_data(), id_var._get_all_var_data() ), ).guess_type() @@ -384,7 +399,7 @@ def create(cls, *children, **props) -> Component: var_data = VarData.merge( VarData( imports=Imports.EVENTS, - hooks={Hooks.EVENTS: None}, + app_wraps=get_event_app_wraps(), ), use_dropzone_arguments._get_all_var_data(), VarData( @@ -442,12 +457,6 @@ def _update_arg_tuple_for_on_drop(cls, arg_value: tuple[Var, Var]): return (arg_value[0], placeholder) return arg_value - @staticmethod - def _get_app_wrap_components() -> dict[tuple[int, str], Component]: - return { - (5, "UploadFilesProvider"): UploadFilesProvider.create(), - } - class StyledUpload(Upload): """The styled Upload Component.""" diff --git a/pyi_hashes.json b/pyi_hashes.json index 801f1660679..6971b09327e 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": "2dd6ba6e3a4d61fc1d79eb582a7cc548", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "0810ae4f1aa3c8fcaa228e7555c59f9a", "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", diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 39ff4931a9e..e075736bc45 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -35,6 +35,7 @@ from reflex.compiler import templates, utils from reflex.compiler.plugins import default_page_plugins +from reflex.compiler.plugins.builtin import collect_var_app_wraps_in_subtree from reflex.experimental.memo import ( EXPERIMENTAL_MEMOS, ExperimentalMemoComponentDefinition, @@ -958,6 +959,21 @@ def memoized_toast_provider(): if component is not None: app_wrappers[key] = component + # The page collector only walks pages, but app-wrap components have their + # own subtrees (e.g. ``ErrorBoundary``'s fallback render). Surface their + # Var-declared ``app_wraps`` here, fixpoint-iterating because newly added + # wraps may themselves contain further declarations. + pending: list[Component] = list(app_wrappers.values()) + while pending: + next_pending: list[Component] = [] + for wrapper in pending: + before = set(app_wrappers) + collect_var_app_wraps_in_subtree(app_wrappers, wrapper) + next_pending.extend( + app_wrappers[key] for key in app_wrappers.keys() - before + ) + pending = next_pending + return app_wrappers diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index a4b326be4ab..3f2a2940b4f 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -7,7 +7,9 @@ from typing import Any from reflex_base.components.component import BaseComponent, Component, ComponentStyle +from reflex_base.components.state_context import get_events_hooks_var_data from reflex_base.config import get_config +from reflex_base.constants.compiler import Hooks 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 @@ -18,6 +20,115 @@ from reflex.compiler import utils +def collect_var_app_wraps_in_subtree( + page_app_wrap_components: dict[tuple[int, str], Component], + root: Component, +) -> None: + """Walk ``root`` and its descendants, surfacing Var-declared app_wraps. + + Each visited component contributes via :func:`collect_var_app_wraps_for_component`. + Used wherever the page walker doesn't reach — e.g. snapshot-boundary + descendants sealed by ``MemoizeStatefulPlugin``, or the app-wrap chain + components assembled by ``App._app_root`` (their own subtrees, e.g. + ``ErrorBoundary``'s fallback render, are not pages). + """ + visited: set[int] = set() + stack: list[Component] = [root] + while stack: + node = stack.pop() + node_id = id(node) + if node_id in visited: + continue + visited.add(node_id) + page_app_wrap_components.update( + collect_var_app_wraps_for_component(page_app_wrap_components, node) + ) + stack.extend(child for child in node.children if isinstance(child, Component)) + stack.extend( + component + for component in node._get_components_in_props() + if isinstance(component, Component) + ) + + +def collect_var_app_wraps_for_component( + page_app_wrap_components: dict[tuple[int, str], Component], + component: Component, +) -> dict[tuple[int, str], Component]: + """Return Var-declared app_wraps newly contributed by ``component``. + + Scans the component's Vars (props/style/event-trigger args), the + VarData attached to its framework-managed internal hooks (e.g. + ``Hooks.EVENTS``), and any state/event-loop providers it requires + via :meth:`Component._get_event_app_wraps` — so the providers needed + for ``addEvents`` dispatch surface even when the page walker only + sees a memoization wrapper for the original event-triggering node. + + Entries already in ``page_app_wrap_components`` are skipped, leaving the + caller to decide how to merge the result and whether to recurse into + each wrapper's own subtree. + + Returns: + Mapping of ``(priority, name)`` -> wrapper for new entries only. + """ + wraps_by_key: dict[tuple[int, str], Component] = {} + for var in component._get_vars(): + var_data = var._get_all_var_data() + if var_data is None: + continue + _ingest_var_data_app_wraps(wraps_by_key, page_app_wrap_components, var_data) + for hook_var_data in component._get_hooks_internal().values(): + if hook_var_data is None: + continue + _ingest_var_data_app_wraps( + wraps_by_key, page_app_wrap_components, hook_var_data + ) + for hook, hook_var_data in component._get_added_hooks().items(): + if hook_var_data is None and hook == Hooks.EVENTS: + hook_var_data = get_events_hooks_var_data() + if hook_var_data is None: + continue + _ingest_var_data_app_wraps( + wraps_by_key, page_app_wrap_components, hook_var_data + ) + for key, wrapper in component._get_event_app_wraps().items(): + if key in page_app_wrap_components or key in wraps_by_key: + continue + wraps_by_key[key] = wrapper + return wraps_by_key + + +def _ingest_var_data_app_wraps( + wraps_by_key: dict[tuple[int, str], Component], + existing: dict[tuple[int, str], Component], + var_data: VarData, +) -> None: + """Insert app_wraps carried or implied by ``var_data``.""" + if var_data.app_wraps: + _ingest_app_wraps(wraps_by_key, existing, var_data.app_wraps) + if Hooks.EVENTS in var_data.hooks: + _ingest_app_wraps( + wraps_by_key, + existing, + get_events_hooks_var_data().app_wraps, + ) + + +def _ingest_app_wraps( + wraps_by_key: dict[tuple[int, str], Component], + existing: dict[tuple[int, str], Component], + app_wraps: tuple[tuple[int, BaseComponent], ...], +) -> None: + """Insert app_wraps not already present in ``existing`` or ``wraps_by_key``.""" + for priority, wrapper in app_wraps: + if not isinstance(wrapper, Component): + continue + key = (priority, wrapper.tag or type(wrapper).__name__) + if key in existing or key in wraps_by_key: + continue + wraps_by_key[key] = wrapper + + @dataclasses.dataclass(frozen=True, slots=True) class DefaultPagePlugin(Plugin): """Evaluate an unevaluated page into a mutable page context.""" @@ -198,12 +309,14 @@ def leave_component( if ( type(comp)._get_app_wrap_components is not Component._get_app_wrap_components - ): + ) or comp.event_triggers: self._collect_app_wrap_components( page_context.app_wrap_components, comp, ) + self._collect_var_app_wraps(page_context.app_wrap_components, comp) + if (dynamic_import := comp._get_dynamic_imports()) is not None: page_context.dynamic_imports.add(dynamic_import) @@ -253,6 +366,7 @@ def _compiler_bind_leave_component( 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 + collect_var_app_wraps = self._collect_var_app_wraps base_get_app_wrap_components = Component._get_app_wrap_components seen_app_wrap_methods: set[object] = set() @@ -274,13 +388,19 @@ def leave_component( collect_component_hooks(hooks, comp) app_wrap_method = type(comp)._get_app_wrap_components - if ( + has_subclass_override = ( app_wrap_method is not base_get_app_wrap_components + ) + if ( + has_subclass_override and app_wrap_method not in seen_app_wrap_methods - ): - seen_app_wrap_methods.add(app_wrap_method) + ) or comp.event_triggers: + if has_subclass_override: + seen_app_wrap_methods.add(app_wrap_method) collect_app_wrap_components(app_wrap_components, comp) + collect_var_app_wraps(app_wrap_components, comp) + dynamic_import = comp._get_dynamic_imports() if dynamic_import is not None: dynamic_imports.add(dynamic_import) @@ -336,6 +456,12 @@ def _collect_app_wrap_components( ) -> None: """Collect app-wrap components for a structural-tree component.""" direct_wrappers = component._get_app_wrap_components() + # Event-trigger providers ride alongside subclass-declared wraps so + # subclass overrides of ``_get_app_wrap_components`` (e.g. radix + # color-mode) don't strip them. ``setdefault`` preserves a wrap a + # subclass already declared at the same key. + for key, wrapper in component._get_event_app_wraps().items(): + direct_wrappers.setdefault(key, wrapper) if not direct_wrappers: return @@ -352,6 +478,31 @@ def _collect_app_wrap_components( page_app_wrap_components, ) + def _collect_var_app_wraps( + self, + page_app_wrap_components: dict[tuple[int, str], Component], + component: Component, + ) -> None: + """Collect app-wrap components declared by VarData on ``component``.""" + wraps_by_key = collect_var_app_wraps_for_component( + page_app_wrap_components, component + ) + if not wraps_by_key: + return + + ignore_ids = {id(wrapper) for wrapper in page_app_wrap_components.values()} + page_app_wrap_components.update(wraps_by_key) + for wrapper in wraps_by_key.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, diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index b596b147a79..8147f9904c1 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -39,6 +39,10 @@ from reflex_base.plugins.base import Plugin from reflex_base.utils import format +from reflex.compiler.plugins.builtin import ( + collect_var_app_wraps_for_component, + collect_var_app_wraps_in_subtree, +) from reflex.experimental.memo import create_passthrough_component_memo @@ -136,6 +140,12 @@ def _component_subtree_is_reactive( return True if component._get_hooks() is not None or component._get_added_hooks(): return True + # ``addEvents`` no longer hoists a hook here (it's reached via the + # module-level import in ``Imports.EVENTS``), so a no-arg + # ``on_click=State.ping`` only shows up as ``event_triggers`` — without + # this check the boundary skips memoization and the callback leaks. + if component.event_triggers: + return True for var in component._get_vars(include_children=False): var_data = var._get_all_var_data() if var_data is None: @@ -297,7 +307,15 @@ def enter_component( page_context, compile_context, ) - return None if wrapper is None else (wrapper, ()) + if wrapper is not None: + # Snapshot-boundary descendants are sealed from the page walker, so + # ``DefaultCollectorPlugin._collect_var_app_wraps`` never sees Vars + # buried inside the boundary. Surface their app_wraps now (before + # sealing) so providers declared via ``VarData.app_wraps`` still + # reach the page-level app_wrap registry. + collect_var_app_wraps_in_subtree(page_context.app_wrap_components, comp) + return (wrapper, ()) + return None def leave_component( self, @@ -350,6 +368,16 @@ def leave_component( if not _should_memoize(comp): return None + # The collector plugin runs at HookOrder.POST — *after* this plugin + # rewrites ``comp`` into its wrapper — so the wrapper (which holds no + # Vars of its own) is what the collector sees for the current node. + # Surface ``comp``'s own Var-declared app_wraps before returning the + # wrapper. Descendants have already been visited by the collector, so + # this only needs to look at the current node — not its subtree. + page_context.app_wrap_components.update( + collect_var_app_wraps_for_component(page_context.app_wrap_components, comp) + ) + return self._build_wrapper(comp, page_context, compile_context) @staticmethod diff --git a/tests/units/compiler/test_dynamic_components_codegen.py b/tests/units/compiler/test_dynamic_components_codegen.py index fe859984346..7f9344a149f 100644 --- a/tests/units/compiler/test_dynamic_components_codegen.py +++ b/tests/units/compiler/test_dynamic_components_codegen.py @@ -25,13 +25,15 @@ def test_dynamic_component_codegen_wires_event_handlers() -> None: 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 {Fragment,useEffect}" in code + # ``addEvents`` is now a module-level callable in ``$/utils/context``; + # no more ``useContext(EventLoopContext)`` hoist needed for dispatch. + assert "const {addEvents} = 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 "useContext(EventLoopContext)" not in code assert code.count("onClick:") == 2 assert code.count("addEvents(") == 2 assert code.count("ReflexEvent(") == 2 @@ -91,13 +93,13 @@ def counter_ui(self) -> rx.Component: 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 {Fragment,useEffect}" in code + assert "const {addEvents} = 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 "useContext(EventLoopContext)" not in code assert code.count("onClick:") == 2 assert code.count("addEvents(") == 2 assert code.count("ReflexEvent(") == 2 diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 18fc3b3e5dc..e8ca4db81ef 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -71,6 +71,19 @@ class LeafComponent(Component): _memoization_mode = MemoizationMode(recursive=False) +class SnapshotWithSlot(Component): + tag = "SnapshotWithSlot" + library = "snapshot-with-slot-lib" + _memoization_mode = MemoizationMode(recursive=False) + + slot: Component | None = field(default=None) + + +class MemoAppWrapProvider(Component): + tag = "MemoAppWrapProvider" + library = "memo-app-wrap-provider-lib" + + class SpecialFormMemoState(BaseState): items: list[str] = ["a"] flag: bool = True @@ -192,6 +205,40 @@ def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None: ) == 1 +def test_passthrough_memo_collects_var_app_wraps_from_replaced_component() -> None: + """Var app_wraps on passthrough-memoized components survive replacement.""" + provider = MemoAppWrapProvider.create() + stateful_var_with_wrap = LiteralVar.create("needs-wrap")._replace( + merge_var_data=VarData( + hooks={"useNeedsWrap": None}, + app_wraps=((70, provider),), + ) + ) + + _ctx, page_ctx = _compile_single_page( + lambda: WithProp.create(label=stateful_var_with_wrap) + ) + + assert (70, "MemoAppWrapProvider") in page_ctx.app_wrap_components + + +def test_snapshot_memo_collects_var_app_wraps_from_prop_components() -> None: + """Snapshot memo boundaries collect app_wraps buried in prop components.""" + provider = MemoAppWrapProvider.create() + var_with_wrap = LiteralVar.create("needs-wrap")._replace( + merge_var_data=VarData(app_wraps=((70, provider),)) + ) + + _ctx, page_ctx = _compile_single_page( + lambda: SnapshotWithSlot.create( + STATE_VAR, + slot=WithProp.create(label=var_with_wrap), + ) + ) + + assert (70, "MemoAppWrapProvider") in page_ctx.app_wrap_components + + @pytest.mark.parametrize( ("special_form", "body_marker"), [ diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 26eb1f39c99..03ff87b0a02 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -11,6 +11,7 @@ ComponentStyle, field, ) +from reflex_base.constants.compiler import Hooks from reflex_base.plugins import ( BaseContext, CompileContext, @@ -769,6 +770,101 @@ def test_default_collector_matches_legacy_collectors() -> None: ) +class StubVarProvider(Component): + tag = "StubVarProvider" + library = "stub-provider-lib" + + +class DirectEventsHookComponent(Component): + tag = "DirectEventsHookComponent" + library = "direct-events-hook-lib" + + def add_hooks(self) -> list[str]: + """Add the shared event-loop hook directly. + + Returns: + A list with just the events hook. + """ + return [Hooks.EVENTS] + + +def test_default_collector_collects_var_app_wraps() -> None: + """A Var with app_wraps in its VarData injects the wrapper into the page registry.""" + provider = StubVarProvider.create() + var_with_wrap = LiteralVar.create("hello")._replace( + merge_var_data=VarData(app_wraps=((50, provider),)) + ) + + component = RootComponent.create( + ChildComponent.create(id=var_with_wrap), + ) + + page_ctx = collect_page_context( + component, + plugins=(DefaultCollectorPlugin(),), + ) + + assert (50, "StubVarProvider") in page_ctx.app_wrap_components + assert page_ctx.app_wrap_components[50, "StubVarProvider"] is provider + # Existing component-declared wraps are still collected. + assert (10, "Wrap") in page_ctx.app_wrap_components + + +def test_default_collector_dedupes_var_app_wraps_against_component_wraps() -> None: + """A Var-declared wrap with the same (priority, name) as a Component-declared one defers.""" + component_wrap = WrapperComponent.create() + var_wrap = WrapperComponent.create() + var_with_wrap = LiteralVar.create("dup")._replace( + merge_var_data=VarData(app_wraps=((10, var_wrap),)) + ) + + class RootWithSameWrap(Component): + tag = "RootWithSameWrap" + library = "root-with-same-wrap-lib" + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + return {(10, "WrapperComponent"): component_wrap} + + component = RootWithSameWrap.create( + ChildComponent.create(id=var_with_wrap), + ) + + page_ctx = collect_page_context( + component, + plugins=(DefaultCollectorPlugin(),), + ) + + # Component-declared wrap wins because it's collected first; var wrap is skipped. + assert page_ctx.app_wrap_components[10, "WrapperComponent"] is component_wrap + + +def test_default_collector_collects_direct_events_hook_app_wraps() -> None: + """Direct ``Hooks.EVENTS`` users collect the state/event providers.""" + page_ctx = collect_page_context( + DirectEventsHookComponent.create(), + plugins=(DefaultCollectorPlugin(),), + ) + + assert (100, "StateProvider") in page_ctx.app_wrap_components + assert (90, "EventLoopProvider") in page_ctx.app_wrap_components + + +def test_default_collector_collects_var_events_hook_app_wraps() -> None: + """Vars with raw ``Hooks.EVENTS`` metadata collect fresh event providers.""" + var_with_events_hook = LiteralVar.create("hello")._replace( + merge_var_data=VarData(hooks={Hooks.EVENTS: None}) + ) + + page_ctx = collect_page_context( + RootComponent.create(ChildComponent.create(id=var_with_events_hook)), + plugins=(DefaultCollectorPlugin(),), + ) + + assert (100, "StateProvider") in page_ctx.app_wrap_components + assert (90, "EventLoopProvider") in page_ctx.app_wrap_components + + def test_default_collector_collects_nested_prop_tree_custom_code_without_recursion() -> ( None ): diff --git a/tests/units/components/core/test_upload.py b/tests/units/components/core/test_upload.py index d3a1e4c19aa..2bb990bac56 100644 --- a/tests/units/components/core/test_upload.py +++ b/tests/units/components/core/test_upload.py @@ -2,6 +2,7 @@ import pytest from reflex_base.event import EventChain, EventHandler, EventSpec, parse_args_spec +from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var from reflex_components_core.core.upload import ( GhostUpload, @@ -199,6 +200,28 @@ def test_upload_button_handlers_allow_custom_param_names(): assert chunk_arg_names[:3] == ["files", "stream", "upload_param_name"] +def test_upload_files_event_spec_carries_upload_provider_app_wrap(): + """Upload button event specs carry UploadFilesProvider through VarData.""" + button = rx.button( + "Upload", + on_click=UploadStateTest.drop_handler( + cast(Any, rx.upload_files(upload_id="foo_id")) + ), + ) + chain = cast(EventChain, button.event_triggers["on_click"]) + upload_event = cast(EventSpec, chain.events[0]) + + var_data = VarData.merge( + *(arg_value._get_all_var_data() for _, arg_value in upload_event.args) + ) + + assert var_data is not None + assert any( + priority == 5 and wrapper.tag == "UploadFilesProvider" + for priority, wrapper in var_data.app_wraps + ) + + def test_styled_upload_create(): styled_up_comp_1 = StyledUpload.create() assert isinstance(styled_up_comp_1, StyledUpload) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index a772b3a3eae..1bda7259da8 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -22,12 +22,14 @@ from reflex_base.event import Event from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor +from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.registry import RegistrationContext from reflex_base.style import Style from reflex_base.utils import console, exceptions, format from reflex_base.vars.base import computed_var from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment +from reflex_components_core.core.upload import selected_files from reflex_components_radix.themes.typography.text import Text from starlette.applications import Starlette from starlette.datastructures import FormData, Headers, UploadFile @@ -38,19 +40,16 @@ import reflex as rx from reflex import AdminDash, constants from reflex.app import App, ComponentCallable, upload +from reflex.compiler.compiler import _compile_app, _resolve_app_wrap_components +from reflex.compiler.plugins import default_page_plugins from reflex.environment import environment +from reflex.istate.data import RouterData from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.redis import StateManagerRedis from reflex.istate.manager.token import BaseStateToken from reflex.model import Model -from reflex.state import ( - BaseState, - OnLoadInternalState, - RouterData, - State, - reload_state_module, -) +from reflex.state import BaseState, OnLoadInternalState, State, reload_state_module from .conftest import chdir from .states import GenState @@ -2021,11 +2020,15 @@ async def test_process_events( @pytest.fixture -def compilable_app(tmp_path: Path) -> Generator[tuple[App, Path], None, None]: +def compilable_app( + tmp_path: Path, + forked_registration_context: RegistrationContext, +) -> Generator[tuple[App, Path], None, None]: """Fixture for an app that can be compiled. Args: tmp_path: Temporary path. + forked_registration_context: Isolated state/event registration context. Yields: Tuple containing (app instance, Path to ".web" directory) @@ -2045,15 +2048,64 @@ def compilable_app(tmp_path: Path) -> Generator[tuple[App, Path], None, None]: "postcss-import": {}, autoprefixer: {}, }, -}; -""", + }; + """, ) + reload_state_module(__name__) app = App(theme=None) app._get_frontend_packages = unittest.mock.Mock() with chdir(app_path): yield app, web_dir +EVENT_LOOP_CONTEXT_HOOK = ( + "const [addEvents, connectErrors] = useContext(EventLoopContext);" +) + + +def compile_page_context_for_app_wraps(component: Component): + """Compile one component through the page plugin pipeline. + + Args: + component: The page root component to compile. + + Returns: + The compiled page context. + """ + page_ctx = PageContext(name="page", route="/page", root_component=component) + page_hooks = CompilerHooks(plugins=default_page_plugins(style=None)) + compile_ctx = CompileContext(pages=[], hooks=page_hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = page_hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + page_hooks.compile_page(page_ctx, compile_context=compile_ctx) + + return page_ctx + + +def compile_app_root_from_page_wraps( + app: App, + page_app_wrap_components: dict[tuple[int, str], Component], +) -> str: + """Render app-root code from an app and pre-collected page app wraps. + + Args: + app: The app whose root wrapper chain should be compiled. + page_app_wrap_components: The app wraps collected from page compilation. + + Returns: + The generated app root source. + """ + app_root = app._app_root( + _resolve_app_wrap_components(app, page_app_wrap_components) + ) + return _compile_app(app_root) + + @pytest.mark.parametrize( "react_strict_mode", [True, False], @@ -2079,17 +2131,22 @@ def test_app_wrap_compile_theme( app_js_contents = ( web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT ).read_text() + # AppWrap renders the entire chain in its body. ``addEvents`` is now a + # module-level callable (see ``$/utils/context``) so no + # ``useContext(EventLoopContext)`` hoist is needed; the events hook + # block is empty. State/event-loop providers ride along as the highest + # collected app wraps, ahead of ErrorBoundary etc. function_app_definition = app_js_contents[ app_js_contents.index("function AppWrap") : app_js_contents.index( "export function Layout" ) ].strip() - expected = ( - "function AppWrap({children}) {\n" - "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" + "function AppWrap({children}) {\n\n\n\n\n" "return (" + ("jsx(StrictMode,{}," if react_strict_mode else "") + + "jsx(StateProvider,{}," + + "jsx(EventLoopProvider,{}," + "jsx(ErrorBoundary,{" """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_call_function", ({ ["function"] : (() => (navigator?.["clipboard"]?.["writeText"](event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack))), ["callback"] : null }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" """onError:((_error, _info) => (addEvents([(ReflexEvent("reflex___state____state.reflex___state____frontend_event_exception_state.handle_frontend_exception", ({ ["info"] : ((((_error?.["name"]+": ")+_error?.["message"])+"\\n")+_error?.["stack"]), ["component_stack"] : _info?.["componentStack"] }), ({ })))], [_error, _info], ({ }))))""" @@ -2102,8 +2159,9 @@ def test_app_wrap_compile_theme( + "jsx(DefaultOverlayComponents,{},)," + "jsx(Fragment,{}," + "children" - "))))))" + (")" if react_strict_mode else "") + ")" - "\n}" + + "))))))))" + + (")" if react_strict_mode else "") + + ")\n}" ) assert expected.split(",") == function_app_definition.split(",") @@ -2286,6 +2344,162 @@ def test_compile_writes_upload_files_provider_app_wrap( assert "UploadFilesProvider" in root_contents +def test_app_wrap_event_hook_requires_state_providers(mocker: MockerFixture) -> None: + """App-root chain components with event triggers pull state/event providers. + + The default error boundary's fallback render contains a Copy button. + The button has an ``on_click`` handler, so the page collector pulls + ``StateProvider`` and ``EventLoopProvider`` into the app root via the + event-trigger app-wrap path. ``addEvents`` itself reaches its call + sites through the module-level import — no ``useContext`` hoist. + """ + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app = App(theme=None, enable_state=False) + + root_contents = compile_app_root_from_page_wraps(app, {}) + + assert EVENT_LOOP_CONTEXT_HOOK not in root_contents + assert "jsx(StateProvider" in root_contents + assert "jsx(EventLoopProvider" in root_contents + + +def test_event_provider_app_wrap_order(mocker: MockerFixture) -> None: + """StateProvider wraps EventLoopProvider, and both wrap AppWrap.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + page_ctx = compile_page_context_for_app_wraps( + rx.button("ping", on_click=rx.console_log("ping")) + ) + app = App(theme=None, enable_state=False) + + root_contents = compile_app_root_from_page_wraps(app, page_ctx.app_wrap_components) + + state_index = root_contents.index("jsx(StateProvider") + event_loop_index = root_contents.index("jsx(EventLoopProvider") + app_wrap_index = root_contents.index("jsx(AppWrap") + assert state_index < event_loop_index < app_wrap_index + + +def test_minimal_static_app_wrap_omits_state_providers( + mocker: MockerFixture, +) -> None: + """An app-root chain with no event/state hooks avoids state providers.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app = App(theme=None, enable_state=False) + app.app_wraps = {} + app.extra_app_wraps = {} + app.toaster = None + + root_contents = compile_app_root_from_page_wraps(app, {}) + + assert EVENT_LOOP_CONTEXT_HOOK not in root_contents + assert "jsx(StateProvider" not in root_contents + assert "jsx(EventLoopProvider" not in root_contents + + +def test_event_triggers_collect_state_providers_via_var_app_wrap() -> None: + """A component with event triggers collects ``StateProvider`` and + ``EventLoopProvider`` into the page-level app_wrap registry through the + Var-driven path attached to ``Hooks.EVENTS``. + + Uses the page-walk pipeline directly (not ``app._compile()``) so the + assertion is robust against ``rx.State`` registration leaks between + tests in this file. + """ + component = rx.button("ping", on_click=rx.console_log("ping")) + + page_ctx = PageContext(name="page", route="/page", root_component=component) + page_hooks = CompilerHooks(plugins=default_page_plugins(style=None)) + compile_ctx = CompileContext(pages=[], hooks=page_hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = page_hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + page_hooks.compile_page(page_ctx, compile_context=compile_ctx) + + keys = page_ctx.app_wrap_components.keys() + # ``from_state`` priorities: StateProvider outer (100), EventLoopProvider + # inner (90). Both must be present whenever a component carries event + # triggers, since the ``useContext(EventLoopContext)`` hook hoisted at + # ``AppWrap`` level requires both providers as React-tree ancestors. + assert (100, "StateProvider") in keys + assert (90, "EventLoopProvider") in keys + + +def test_no_event_triggers_omits_state_providers() -> None: + """A static page with no event triggers does not pull state providers in. + + Validates that the migration is *conditional* — apps that don't use + state machinery don't pay for unused providers in the app root. + """ + component = rx.text("hello") + + page_ctx = PageContext(name="page", route="/page", root_component=component) + page_hooks = CompilerHooks(plugins=default_page_plugins(style=None)) + compile_ctx = CompileContext(pages=[], hooks=page_hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = page_hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + page_hooks.compile_page(page_ctx, compile_context=compile_ctx) + + keys = page_ctx.app_wrap_components.keys() + assert (100, "StateProvider") not in keys + assert (90, "EventLoopProvider") not in keys + + +def test_compile_writes_upload_files_provider_via_var_app_wrap( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +) -> None: + """A page that uses ``selected_files`` *without* an Upload component still + pulls the UploadFilesProvider in via the Var-declared app_wrap path. + """ + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + + app.add_page( + lambda: rx.text(selected_files("custom-id").to_string()), + 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 + + +def test_selected_files_collects_upload_provider_without_upload_component() -> None: + """``selected_files`` alone pulls in UploadFilesProvider.""" + page_ctx = compile_page_context_for_app_wraps( + rx.text(selected_files("custom-id").to_string()) + ) + + assert (5, "UploadFilesProvider") in page_ctx.app_wrap_components + assert (100, "StateProvider") not in page_ctx.app_wrap_components + assert (90, "EventLoopProvider") not in page_ctx.app_wrap_components + + +def test_upload_root_collects_upload_and_event_providers() -> None: + """Upload root requires both upload context and event-loop providers.""" + page_ctx = compile_page_context_for_app_wraps( + rx.upload.root(rx.button("Select file")) + ) + + assert (5, "UploadFilesProvider") in page_ctx.app_wrap_components + assert (100, "StateProvider") in page_ctx.app_wrap_components + assert (90, "EventLoopProvider") in page_ctx.app_wrap_components + + @pytest.mark.parametrize( "react_strict_mode", [True, False], @@ -2333,19 +2547,22 @@ def page(): app_js_contents = ( web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT ).read_text() + # AppWrap renders the priority-ordered chain inside its body. + # ``addEvents`` is reached via module-level import so the events hook + # block is empty. function_app_definition = app_js_contents[ app_js_contents.index("function AppWrap") : app_js_contents.index( "export function Layout" ) ].strip() - expected = ( - "function AppWrap({children}) {\n" - "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" + "function AppWrap({children}) {\n\n\n\n\n" "return (" + ("jsx(StrictMode,{}," if react_strict_mode else "") + + "jsx(StateProvider,{}," + "jsx(RadixThemesBox,{}," - "jsx(ErrorBoundary,{" + + "jsx(EventLoopProvider,{}," + + "jsx(ErrorBoundary,{" """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_call_function", ({ ["function"] : (() => (navigator?.["clipboard"]?.["writeText"](event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack))), ["callback"] : null }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" """onError:((_error, _info) => (addEvents([(ReflexEvent("reflex___state____state.reflex___state____frontend_event_exception_state.handle_frontend_exception", ({ ["info"] : ((((_error?.["name"]+": ")+_error?.["message"])+"\\n")+_error?.["stack"]), ["component_stack"] : _info?.["componentStack"] }), ({ })))], [_error, _info], ({ }))))""" + "}," @@ -2358,7 +2575,9 @@ def page(): + "jsx(DefaultOverlayComponents,{},)," + "jsx(Fragment,{}," + "children" - ")))))))" + (")" if react_strict_mode else "") + "))\n}" + + "))))))))))" + + (")" if react_strict_mode else "") + + ")\n}" ) assert expected.split(",") == function_app_definition.split(",") diff --git a/tests/units/test_event.py b/tests/units/test_event.py index c332a57b892..9614f69e010 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -17,7 +17,7 @@ fix_events, ) from reflex_base.utils import format -from reflex_base.vars.base import Field, LiteralVar, Var, VarData, field +from reflex_base.vars.base import Field, LiteralVar, Var, field import reflex as rx from reflex.state import BaseState @@ -500,10 +500,21 @@ def _args_spec(value: Var[int]) -> tuple[Var[int]]: )._get_all_var_data() assert chain_var_data is not None - assert chain_var_data == VarData( - imports=Imports.EVENTS, - hooks={Hooks.EVENTS: None}, - ) + # Imports include EVENTS (which now imports module-level ``addEvents``) + # and the state/event-loop providers ride along as app_wraps so the + # compiler can mount them in the app root. ``addEvents`` reaches its + # call sites through the import, not a hoisted hook, so ``hooks`` is + # empty here. Compare structurally — providers are fresh instances per + # call, so identity-based VarData equality wouldn't match. + assert dict(chain_var_data.imports) == { + k: tuple(v) for k, v in Imports.EVENTS.items() + } + assert chain_var_data.hooks == () + assert sorted(p for p, _ in chain_var_data.app_wraps) == [90, 100] + assert {wrapper.tag for _, wrapper in chain_var_data.app_wraps} == { + "StateProvider", + "EventLoopProvider", + } def test_event_chain_statement_block_preserves_nested_var_data(): @@ -529,7 +540,11 @@ def s(self, value: int): assert chain_var_data.state == x_var_data.state assert chain_var_data.field_name == x_var_data.field_name assert x_var_data.hooks[0] in chain_var_data.hooks - assert Hooks.EVENTS in chain_var_data.hooks + # ``addEvents`` is reached via module-level import, so the events hook + # is no longer hoisted on event-chain VarData. State/event-loop providers + # ride on ``app_wraps`` to surface in the app root when needed. + assert Hooks.EVENTS not in chain_var_data.hooks + assert sorted(p for p, _ in chain_var_data.app_wraps) == [90, 100] def test_event_bound_method() -> None: diff --git a/tests/units/test_var.py b/tests/units/test_var.py index a96e4b2fb2e..3a1f43b0145 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -21,6 +21,7 @@ ComputedVar, LiteralVar, Var, + _decode_var_immutable, computed_var, var_operation, var_operation_return, @@ -1909,6 +1910,74 @@ def test_var_data_with_hooks_value(): assert var_data == VarData(hooks=["whott", "whot", "what"]) +def test_var_data_app_wraps_merge(): + """Var-declared app_wraps merge and dedupe by the compiler registry key.""" + wrapper_a = rx.fragment() + wrapper_b = rx.fragment() + + vd_a = VarData(app_wraps=((10, wrapper_a),)) + vd_b = VarData(app_wraps=((20, wrapper_b),)) + vd_dup = VarData(app_wraps=((10, wrapper_a),)) + + merged = VarData.merge(vd_a, vd_b, vd_dup) + assert merged is not None + assert (10, wrapper_a) in merged.app_wraps + assert (20, wrapper_b) in merged.app_wraps + assert len(merged.app_wraps) == 2 + + # Same priority/tag dedupes even when provider instances are fresh. + vd_same_key = VarData(app_wraps=((10, wrapper_b),)) + merged_same_key = VarData.merge(vd_a, vd_same_key) + assert merged_same_key is not None + assert merged_same_key.app_wraps == ((10, wrapper_a),) + + # Same component at a different priority is a distinct entry. + vd_alt = VarData(app_wraps=((30, wrapper_a),)) + merged_alt = VarData.merge(vd_a, vd_alt) + assert merged_alt is not None + assert len(merged_alt.app_wraps) == 2 + + # An empty VarData is falsy; one with app_wraps is truthy. + assert not VarData() + assert VarData(app_wraps=((10, wrapper_a),)) + + +def test_var_data_identity_hashes_component_metadata(): + """VarData hashes component metadata without hashing components directly.""" + wrapper_a = rx.fragment() + wrapper_b = rx.fragment() + + base = VarData(hooks="useFoo") + wrap_with_a = VarData(hooks="useFoo", app_wraps=((10, wrapper_a),)) + wrap_with_b = VarData(hooks="useFoo", app_wraps=((10, wrapper_b),)) + component_with_a = VarData(hooks="useFoo", components=(wrapper_a,)) + component_with_b = VarData(hooks="useFoo", components=(wrapper_b,)) + + assert base != wrap_with_a + assert wrap_with_a == wrap_with_b + assert hash(wrap_with_a) == hash(wrap_with_b) + assert component_with_a != component_with_b + + +def test_var_hash_keeps_app_wrap_metadata_distinct(): + """Formatted Var decode keeps app_wrap metadata when JS identity matches.""" + wrapper = rx.fragment() + var_with_wrap = Var( + _js_expr="same", + _var_type=str, + _var_data=VarData(app_wraps=((10, wrapper),)), + ) + plain_var = Var(_js_expr="same", _var_type=str, _var_data=VarData()) + + decoded_var_data, decoded_js_expr = _decode_var_immutable( + f"{var_with_wrap}{plain_var}" + ) + + assert decoded_js_expr == "samesame" + assert decoded_var_data is not None + assert decoded_var_data.app_wraps == ((10, wrapper),) + + def test_str_var_in_components(mocker: MockerFixture): class StateWithVar(rx.State): field: int = 1