Skip to content

Commit 6f8db6b

Browse files
feat: add app_wraps to VarData for Var-driven provider injection (#6447)
* feat: add app_wraps to VarData for Var-driven provider injection Lets Vars declare app-level wrapper components in their VarData so the compiler can mount providers (state context, event loop, upload, etc.) based on what's actually used, instead of relying on hardcoded chains or special-case logic. State/event-loop providers now ride along on VarData.from_state and the events-hook helper, and UploadFilesProvider is mounted when selected_files/upload_file is referenced — even without an Upload component on the page. Layout renders the assembled chain so AppWrap reduces to hooks + children. * refactor: reach addEvents via module-level import instead of hook hoist EventLoopProvider now populates a module-level addEvents in $/utils/context, so JSX literals constructed outside the React-tree hoist path (e.g. ErrorBoundary.onError) can dispatch events without useContext(EventLoopContext) being lexically in scope. State and event-loop providers ride along on event-invocation VarData.app_wraps (and via _get_event_app_wraps) so they still mount in the app root. connectErrors stays on useContext since it drives re-renders; AppWrap is now a Fragment rendering the chain in its body. * fix: don't memoize subtrees reactive only via no-arg event triggers Per review: a no-arg handler (on_click=State.ping) surfaces only through event_triggers and reaches addEvents via a module-level import, not a hoisted hook. The inline callback carries no reactive data and never drives a re-render, so wrapping it in a memo gains nothing. Drop the event_triggers fast-path from the reactive-data check and let such callbacks render in the page module. Handlers that reference state still surface through the per-Var scan and memoize as before. * fix: clear render cache on Component deepcopy to prevent dropped children App._app_root deep-copies app-wrap components and rebinds their children to assemble the provider chain. __copy__ already drops the render-path caches for this reason, but copy.deepcopy preserved them, so a component rendered during page compilation (e.g. a State/EventLoop/UploadFiles provider injected via VarData.app_wraps) returned its stale, childless _cached_render_result after children were appended. The page content (children/Outlet) was silently dropped and the page rendered blank. Add a __deepcopy__ that mirrors __copy__: the clone is for compile-time mutation, so render caches are not carried over. Extract the shared cache attr list to _COMPILE_CACHE_ATTRS. Regression test fails before the fix. * perf: reuse fetched hooks across page-hook and app-wrap collection The page collector already pulls _get_hooks_internal/_get_added_hooks for page-hook aggregation; pass those dicts into the Var app-wrap scan instead of re-fetching, so each component avoids a second (uncached) _get_added_hooks and _get_event_app_wraps per compile. Event-trigger providers now surface solely through the Var scan, dropping the event_triggers special-case in the subclass-override app-wrap path. * fix: mixed event/function dispatch must reach addEvents via module import The mixed-dispatch path (_dispatch_mixed_event_var, used by on_click=lambda: rx.cond(..., function_var, event_spec)) still emitted the legacy hooks={Hooks.EVENTS: None} hook — const [addEvents, connectErrors] = useContext(EventLoopContext) — but Imports.EVENTS no longer imports EventLoopContext. The rendered component threw "ReferenceError: EventLoopContext is not defined", crashing the page and failing the test_event_chain_click integration tests. Match the other dispatch sites: reach addEvents through the module-level import and ride the state/event-loop providers on app_wraps via get_event_app_wraps(). Drop the now-unused Hooks import. Regression test covers the mixed-dispatch VarData. * test: fix tab indentation in postcss config fixture string * perf: share state/event-loop app-wrap providers as cached singletons Construct StateProvider/EventLoopProvider once and reuse them instead of rebuilding per state Var. This is now safe because consumers deep-copy before rebinding children and the render-cache no longer survives deepcopy, so the shared markers are never mutated; it lets the page-tree deepcopy and render hash memoize them. VarData.add_state now routes through get_event_app_wraps() for the single source of truth. * test: assert upload provider stays out when no upload Vars are used * refactor: centralize app-wrap merge dedup, raise on conflicting slots Extract the per-(priority, tag) app-wrap merge rule into a shared insert_app_wraps primitive used by both VarData.merge and the compiler's page-wide collection, so the dedup logic lives in one place. Two different wrappers claiming the same slot now raise instead of silently keeping the first. Hash app_wraps as a frozenset so merge order no longer affects VarData identity (a + b == b + a).
1 parent ea8155b commit 6f8db6b

24 files changed

Lines changed: 1292 additions & 98 deletions

File tree

news/6447.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

packages/reflex-base/src/reflex_base/compiler/templates.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def app_root_template(
200200
return f"""
201201
{imports_str}
202202
{dynamic_imports_str}
203-
import {{ EventLoopProvider, StateProvider, defaultColorMode }} from "$/utils/context";
203+
import {{ defaultColorMode }} from "$/utils/context";
204204
import {{ ThemeProvider }} from '$/utils/react-theme';
205205
import {{ Layout as AppLayout }} from './_document';
206206
import {{ Outlet }} from 'react-router';
@@ -218,11 +218,7 @@ def app_root_template(
218218
}}, []);
219219
220220
return jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}},
221-
jsx(StateProvider, {{}},
222-
jsx(EventLoopProvider, {{}},
223-
jsx(AppWrap, {{}}, children)
224-
)
225-
)
221+
jsx(AppWrap, {{}}, children)
226222
);
227223
}}
228224
@@ -373,6 +369,24 @@ def context_template(
373369
374370
export const isDevMode = {json.dumps(is_dev_mode)};
375371
372+
// Module-level event dispatchers populated by ``EventLoopProvider`` on each
373+
// render. Components reach addEvents/connectErrors via this import instead of
374+
// hoisting ``useContext(EventLoopContext)`` so JSX literals (e.g.
375+
// ``ErrorBoundary.onError``) constructed in any JS scope can dispatch events
376+
// without depending on lexical hook hoisting.
377+
let _addEventsImpl = (events, args, event_actions) => {{
378+
console.warn("addEvents called before EventLoopProvider mounted", events);
379+
}};
380+
let _connectErrorsImpl = [];
381+
382+
export function addEvents(events, args, event_actions) {{
383+
return _addEventsImpl(events, args, event_actions);
384+
}}
385+
386+
export function getConnectErrors() {{
387+
return _connectErrorsImpl;
388+
}}
389+
376390
export function UploadFilesProvider({{ children }}) {{
377391
const [filesById, setFilesById] = useState({{}})
378392
refs["__clear_selected_files"] = (id) => setFilesById(filesById => {{
@@ -403,14 +417,19 @@ def context_template(
403417
404418
export function EventLoopProvider({{ children }}) {{
405419
const dispatch = useContext(DispatchContext)
406-
const [addEvents, connectErrors] = useEventLoop(
420+
const [addEventsLocal, connectErrors] = useEventLoop(
407421
dispatch,
408422
initialEvents,
409423
clientStorage,
410424
)
425+
// Populate the module-level dispatchers so JSX literals constructed
426+
// outside the React-tree path (e.g. ``ErrorBoundary.onError``) can call
427+
// ``addEvents`` without needing the events hook hoisted in their scope.
428+
_addEventsImpl = addEventsLocal;
429+
_connectErrorsImpl = connectErrors;
411430
return createElement(
412431
EventLoopContext.Provider,
413-
{{ value: [addEvents, connectErrors] }},
432+
{{ value: [addEventsLocal, connectErrors] }},
414433
children
415434
);
416435
}}

packages/reflex-base/src/reflex_base/components/component.py

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import contextlib
6+
import copy
67
import dataclasses
78
import enum
89
import functools
@@ -279,6 +280,15 @@ def _finalize_fields(
279280
}
280281

281282

283+
_COMPILE_CACHE_ATTRS = (
284+
"_cached_render_result",
285+
"_vars_cache",
286+
"_imports_cache",
287+
"_hooks_internal_cache",
288+
"_get_component_prop_property",
289+
)
290+
291+
282292
class BaseComponent(metaclass=BaseComponentMeta):
283293
"""The base class for all Reflex components.
284294
@@ -349,16 +359,36 @@ def __copy__(self) -> BaseComponent:
349359
new._clear_compile_caches()
350360
return new
351361

362+
def __deepcopy__(self, memo: dict[int, Any]) -> BaseComponent:
363+
"""Return a deep copy suitable for compile-time mutation.
364+
365+
Like :meth:`__copy__`, the clone exists for the compiler to mutate
366+
(e.g. rebinding ``children`` while assembling the app-wrap chain in
367+
``App._app_root``), so the render-path caches populated on the
368+
original are not carried over — otherwise ``render()`` on the mutated
369+
clone would return the pre-mutation result. Unlike ``__copy__``,
370+
nested mutable containers are deep-copied so the clone is fully
371+
independent of the original.
372+
373+
Args:
374+
memo: The deepcopy memo mapping object ids to their copies.
375+
376+
Returns:
377+
A deep-copied instance with compile-time caches dropped.
378+
"""
379+
new = self.__class__.__new__(self.__class__)
380+
memo[id(self)] = new
381+
new_dict = vars(new)
382+
for key, value in vars(self).items():
383+
if key in _COMPILE_CACHE_ATTRS:
384+
continue
385+
new_dict[key] = copy.deepcopy(value, memo)
386+
return new
387+
352388
def _clear_compile_caches(self) -> None:
353389
"""Clear cached render/compiler artifacts after compile-time mutation."""
354390
attrs = cast("dict[str, Any]", vars(self))
355-
for attr in (
356-
"_cached_render_result",
357-
"_vars_cache",
358-
"_imports_cache",
359-
"_hooks_internal_cache",
360-
"_get_component_prop_property",
361-
):
391+
for attr in _COMPILE_CACHE_ATTRS:
362392
attrs.pop(attr, None)
363393

364394
def __eq__(self, value: Any) -> bool:
@@ -1970,14 +2000,16 @@ def _get_vars_hooks(self) -> dict[str, VarData | None]:
19702000
def _get_events_hooks(self) -> dict[str, VarData | None]:
19712001
"""Get the hooks required by events referenced in this component.
19722002
2003+
Always empty: ``addEvents`` is reached via the module-level import
2004+
in ``Imports.EVENTS``, so events need no in-scope hook. The state/
2005+
event-loop providers they still depend on are mounted as app wraps
2006+
instead — carried on the event invocation's ``VarData.app_wraps``
2007+
and via :meth:`_get_event_app_wraps`.
2008+
19732009
Returns:
1974-
The hooks for the events.
2010+
An empty dict.
19752011
"""
1976-
return (
1977-
{Hooks.EVENTS: VarData(position=Hooks.HookPosition.INTERNAL)}
1978-
if self.event_triggers
1979-
else {}
1980-
)
2012+
return {}
19812013

19822014
def _get_hooks_internal(self) -> dict[str, VarData | None]:
19832015
"""Get the React hooks for this component managed by the framework.
@@ -2132,6 +2164,35 @@ def _get_app_wrap_components() -> dict[tuple[int, str], Component]:
21322164
"""
21332165
return {}
21342166

2167+
def _get_event_app_wraps(self) -> dict[tuple[int, str], Component]:
2168+
"""Return state/event-loop providers required by event triggers.
2169+
2170+
A component with event triggers calls ``addEvents`` at runtime,
2171+
which only does anything if ``StateProvider`` (supplies the
2172+
dispatch context) and ``EventLoopProvider`` (runs the websocket)
2173+
are mounted as ancestors. ``addEvents`` now comes from a
2174+
module-level import rather than an in-scope hook, so nothing drags
2175+
those providers into the tree on its own — this method requests
2176+
them explicitly as app wraps.
2177+
2178+
Kept separate from :meth:`_get_app_wrap_components` because
2179+
subclasses override that method to add their own app wraps; folding
2180+
these in would let such an override silently drop them.
2181+
2182+
Returns:
2183+
The state/event-loop provider entries (empty if no event
2184+
triggers are bound).
2185+
"""
2186+
if not self.event_triggers:
2187+
return {}
2188+
# Lazy import: state_context imports from this module.
2189+
from reflex_base.components.state_context import get_event_app_wraps
2190+
2191+
return {
2192+
(priority, provider.tag or type(provider).__name__): provider
2193+
for priority, provider in get_event_app_wraps()
2194+
}
2195+
21352196
def _get_all_app_wrap_components(
21362197
self, *, ignore_ids: set[int] | None = None
21372198
) -> dict[tuple[int, str], Component]:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""App-wrap components mounting the state and event-loop React providers.
2+
3+
These wrap children in the ``StateProvider`` / ``EventLoopProvider`` JS
4+
functions emitted into ``utils/context.js`` by ``compile_contexts``. They are
5+
attached to the VarData returned by :meth:`reflex_base.vars.base.VarData.from_state`
6+
so the compiler picks them up through the generic Var-driven app-wrap pipeline,
7+
rather than the JS Layout template hard-coding them around every app.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from reflex_base.components.component import Component
13+
from reflex_base.constants import Dirs
14+
from reflex_base.constants.compiler import Hooks
15+
from reflex_base.vars.base import VarData
16+
17+
18+
class StateContextProvider(Component):
19+
"""App wrap that mounts the React state-context provider around children."""
20+
21+
library = f"$/{Dirs.CONTEXTS_PATH}"
22+
tag = "StateProvider"
23+
24+
25+
class EventLoopContextProvider(Component):
26+
"""App wrap that mounts the websocket event-loop provider around children."""
27+
28+
library = f"$/{Dirs.CONTEXTS_PATH}"
29+
tag = "EventLoopProvider"
30+
31+
32+
_event_app_wraps: tuple[tuple[int, Component], ...] | None = None
33+
34+
35+
def get_event_app_wraps() -> tuple[tuple[int, Component], ...]:
36+
"""Return state/event-loop providers required when events are dispatched.
37+
38+
``StateProvider`` (100) wraps further out than ``EventLoopProvider`` (90)
39+
because the latter reads ``DispatchContext`` from the former.
40+
41+
The two providers are created once and shared: they are immutable markers
42+
deduped by ``(priority, tag)``, and every consumer (``App._app_root``)
43+
deep-copies them before rebinding children, so the shared instances are
44+
never mutated in place. Sharing also lets the page-tree deepcopy and the
45+
render-hash memoize them instead of paying per state Var.
46+
47+
Returns:
48+
``(priority, provider)`` entries deduped by the compiler.
49+
"""
50+
global _event_app_wraps
51+
if _event_app_wraps is None:
52+
_event_app_wraps = (
53+
(100, StateContextProvider.create()),
54+
(90, EventLoopContextProvider.create()),
55+
)
56+
return _event_app_wraps
57+
58+
59+
def get_events_hooks_var_data() -> VarData:
60+
"""Build the VarData advertising the state/event-loop app wraps.
61+
62+
Returns:
63+
A new VarData carrying both providers as app_wraps.
64+
"""
65+
return VarData(
66+
position=Hooks.HookPosition.INTERNAL,
67+
app_wraps=get_event_app_wraps(),
68+
)

packages/reflex-base/src/reflex_base/constants/compiler.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,20 +148,34 @@ class CompileContext(str, Enum):
148148
class Imports(SimpleNamespace):
149149
"""Common sets of import vars."""
150150

151+
# ``addEvents`` is a module-level callable populated by
152+
# ``EventLoopProvider``; importing it sidesteps the lexical-scope
153+
# constraint a ``useContext(EventLoopContext)`` hoist would impose.
151154
EVENTS = {
152-
"react": [ImportVar(tag="useContext")],
153-
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
155+
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag=CompileVars.ADD_EVENTS)],
154156
f"$/{Dirs.STATE_PATH}": [
155157
ImportVar(tag=CompileVars.TO_EVENT),
156158
ImportVar(tag=CompileVars.APPLY_EVENT_ACTIONS),
157159
],
158160
}
159161

162+
# ``connectErrors`` is reactive — it drives connection-banner
163+
# re-renders — so its consumers still go through ``useContext``.
164+
CONNECT_ERRORS = {
165+
"react": [ImportVar(tag="useContext")],
166+
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
167+
}
168+
160169

161170
class Hooks(SimpleNamespace):
162171
"""Common sets of hook declarations."""
163172

173+
# Kept for legacy callers that still key off this string; the
174+
# compiler no longer auto-hoists it.
164175
EVENTS = f"const [{CompileVars.ADD_EVENTS}, {CompileVars.CONNECT_ERROR}] = useContext(EventLoopContext);"
176+
CONNECT_ERRORS = (
177+
f"const {CompileVars.CONNECT_ERROR} = useContext(EventLoopContext)[1];"
178+
)
165179

166180
class HookPosition(enum.Enum):
167181
"""The position of the hook in the component."""

packages/reflex-base/src/reflex_base/event/__init__.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
from reflex_base import constants
3131
from reflex_base.components.field import BaseField
32-
from reflex_base.constants.compiler import CompileVars, Hooks, Imports
32+
from reflex_base.constants.compiler import CompileVars, Imports
3333
from reflex_base.utils import format
3434
from reflex_base.utils.decorator import once
3535
from reflex_base.utils.exceptions import (
@@ -1080,14 +1080,14 @@ def _as_event_spec(
10801080
"""
10811081
from reflex_components_core.core.upload import (
10821082
DEFAULT_UPLOAD_ID,
1083-
upload_files_context_var_data,
1083+
get_upload_files_context_var_data,
10841084
)
10851085

10861086
upload_id = self.upload_id if self.upload_id is not None else DEFAULT_UPLOAD_ID
10871087
upload_files_var = Var(
10881088
_js_expr="filesById",
10891089
_var_type=dict[str, Any],
1090-
_var_data=VarData.merge(upload_files_context_var_data),
1090+
_var_data=VarData.merge(get_upload_files_context_var_data()),
10911091
).to(ObjectVar)[LiteralVar.create(upload_id)]
10921092
spec_args = [
10931093
(
@@ -2098,11 +2098,14 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:
20982098
_js_expr=f'typeof {alias_name} === "function"',
20992099
_var_type=bool,
21002100
)
2101+
# Lazy import: state_context → component → event (this module).
2102+
from reflex_base.components.state_context import get_event_app_wraps
2103+
21012104
add_events = FunctionStringVar.create(
21022105
CompileVars.ADD_EVENTS,
21032106
_var_data=VarData(
21042107
imports=Imports.EVENTS,
2105-
hooks={Hooks.EVENTS: None},
2108+
app_wraps=get_event_app_wraps(),
21062109
),
21072110
)
21082111
dispatch_expr = ternary_operation(
@@ -2418,11 +2421,14 @@ def create(
24182421
arg_def_expr = Var(_js_expr="args")
24192422

24202423
if value.invocation is None:
2424+
# Lazy import: state_context → component → event (this module).
2425+
from reflex_base.components.state_context import get_event_app_wraps
2426+
24212427
invocation = FunctionStringVar.create(
24222428
CompileVars.ADD_EVENTS,
24232429
_var_data=VarData(
24242430
imports=Imports.EVENTS,
2425-
hooks={Hooks.EVENTS: None},
2431+
app_wraps=get_event_app_wraps(),
24262432
),
24272433
)
24282434
else:
@@ -2463,11 +2469,14 @@ def create(
24632469
_js_expr=f"{{{''.join(f'{statement};' for statement in statements)}}}",
24642470
)
24652471
if value.event_actions:
2472+
# Lazy import: state_context → component → event (this module).
2473+
from reflex_base.components.state_context import get_event_app_wraps
2474+
24662475
apply_event_actions = FunctionStringVar.create(
24672476
CompileVars.APPLY_EVENT_ACTIONS,
24682477
_var_data=VarData(
24692478
imports=Imports.EVENTS,
2470-
hooks={Hooks.EVENTS: None},
2479+
app_wraps=get_event_app_wraps(),
24712480
),
24722481
)
24732482
return_expr = apply_event_actions.call(

0 commit comments

Comments
 (0)