Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6447.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Event handlers attached to JSX literals built outside a component's render scope — such as an `ErrorBoundary`'s `onError` — can now dispatch events. `addEvents` is reached through a module-level import that `EventLoopProvider` populates on each render, so dispatch no longer depends on a `useContext` hook being hoisted into the calling scope. The state and event-loop providers, previously hard-coded in the layout template, are now injected around the app root by the compiler from the `app_wraps` declared on the `Var`s that use them.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6447.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`VarData` gained an `app_wraps` field so a `Var` can declare the app-level wrapper components it requires; the compiler injects them around the app root, deduped by `(priority, tag)`. This is how the state and event-loop providers now reach the React tree, since event dispatch reaches `addEvents` via a module-level import (`Imports.EVENTS`) rather than a hoisted hook. The still-reactive `connectErrors` value moves to its own `CONNECT_ERRORS` import/hook, and `Component` deep copies now drop the render cache so compile-time clones (e.g. the app-root wrapper chain) render their mutated children.
35 changes: 27 additions & 8 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -218,11 +218,7 @@ def app_root_template(
}}, []);

return jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}},
jsx(StateProvider, {{}},
jsx(EventLoopProvider, {{}},
jsx(AppWrap, {{}}, children)
)
)
jsx(AppWrap, {{}}, children)
);
}}

Expand Down Expand Up @@ -373,6 +369,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 => {{
Expand Down Expand Up @@ -403,14 +417,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
);
}}
Expand Down
87 changes: 74 additions & 13 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import contextlib
import copy
import dataclasses
import enum
import functools
Expand Down Expand Up @@ -279,6 +280,15 @@ def _finalize_fields(
}


_COMPILE_CACHE_ATTRS = (
"_cached_render_result",
"_vars_cache",
"_imports_cache",
"_hooks_internal_cache",
"_get_component_prop_property",
)


class BaseComponent(metaclass=BaseComponentMeta):
"""The base class for all Reflex components.

Expand Down Expand Up @@ -349,16 +359,36 @@ def __copy__(self) -> BaseComponent:
new._clear_compile_caches()
return new

def __deepcopy__(self, memo: dict[int, Any]) -> BaseComponent:
"""Return a deep copy suitable for compile-time mutation.

Like :meth:`__copy__`, the clone exists for the compiler to mutate
(e.g. rebinding ``children`` while assembling the app-wrap chain in
``App._app_root``), so the render-path caches populated on the
original are not carried over — otherwise ``render()`` on the mutated
clone would return the pre-mutation result. Unlike ``__copy__``,
nested mutable containers are deep-copied so the clone is fully
independent of the original.

Args:
memo: The deepcopy memo mapping object ids to their copies.

Returns:
A deep-copied instance with compile-time caches dropped.
"""
new = self.__class__.__new__(self.__class__)
memo[id(self)] = new
new_dict = vars(new)
for key, value in vars(self).items():
if key in _COMPILE_CACHE_ATTRS:
continue
new_dict[key] = copy.deepcopy(value, memo)
return new

def _clear_compile_caches(self) -> None:
"""Clear cached render/compiler artifacts after compile-time mutation."""
attrs = cast("dict[str, Any]", vars(self))
for attr in (
"_cached_render_result",
"_vars_cache",
"_imports_cache",
"_hooks_internal_cache",
"_get_component_prop_property",
):
for attr in _COMPILE_CACHE_ATTRS:
attrs.pop(attr, None)

def __eq__(self, value: Any) -> bool:
Expand Down Expand Up @@ -1970,14 +2000,16 @@ 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.

Always empty: ``addEvents`` is reached via the module-level import
in ``Imports.EVENTS``, so events need no in-scope hook. The state/
event-loop providers they still depend on are mounted as app wraps
instead — carried 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.
Expand Down Expand Up @@ -2132,6 +2164,35 @@ 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.

A component with event triggers calls ``addEvents`` at runtime,
which only does anything if ``StateProvider`` (supplies the
dispatch context) and ``EventLoopProvider`` (runs the websocket)
are mounted as ancestors. ``addEvents`` now comes from a
module-level import rather than an in-scope hook, so nothing drags
those providers into the tree on its own — this method requests
them explicitly as app wraps.

Kept separate from :meth:`_get_app_wrap_components` because
subclasses override that method to add their own app wraps; folding
these in would let such an override silently drop them.

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]:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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"


_event_app_wraps: tuple[tuple[int, Component], ...] | None = None


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.

The two providers are created once and shared: they are immutable markers
deduped by ``(priority, tag)``, and every consumer (``App._app_root``)
deep-copies them before rebinding children, so the shared instances are
never mutated in place. Sharing also lets the page-tree deepcopy and the
render-hash memoize them instead of paying per state Var.

Returns:
``(priority, provider)`` entries deduped by the compiler.
"""
global _event_app_wraps
if _event_app_wraps is None:
_event_app_wraps = (
(100, StateContextProvider.create()),
(90, EventLoopContextProvider.create()),
)
return _event_app_wraps


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(),
)
18 changes: 16 additions & 2 deletions packages/reflex-base/src/reflex_base/constants/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,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."""
Expand Down
21 changes: 15 additions & 6 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from reflex_base import constants
from reflex_base.components.field import BaseField
from reflex_base.constants.compiler import CompileVars, Hooks, Imports
from reflex_base.constants.compiler import CompileVars, Imports
from reflex_base.utils import format
from reflex_base.utils.decorator import once
from reflex_base.utils.exceptions import (
Expand Down Expand Up @@ -1080,14 +1080,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 = [
(
Expand Down Expand Up @@ -2098,11 +2098,14 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:
_js_expr=f'typeof {alias_name} === "function"',
_var_type=bool,
)
# Lazy import: state_context → component → event (this module).
from reflex_base.components.state_context import get_event_app_wraps

add_events = FunctionStringVar.create(
CompileVars.ADD_EVENTS,
_var_data=VarData(
imports=Imports.EVENTS,
hooks={Hooks.EVENTS: None},
app_wraps=get_event_app_wraps(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we're putting the app wraps on the event chains themselves, do we still need Component._get_event_app_wraps? they should be picked up because they're on the event vars i would think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At walk time the EventChain is not converted to Var yet. So we can not read it. Maybe if we added some way to store on EventChain, then maybe we can delete it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, let's get a backlog ticket capturing some of that context, but we don't have to prioritize it.

),
)
dispatch_expr = ternary_operation(
Expand Down Expand Up @@ -2418,11 +2421,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:
Expand Down Expand Up @@ -2463,11 +2469,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(
Expand Down
Loading
Loading