From 6656b049ad72b076f6bc33fff564cf10fde032b6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 18 Mar 2026 03:37:15 +0500 Subject: [PATCH 01/15] Support FunctionVar handlers in EventChain rendering Allow EventChain to accept frontend FunctionVar handlers alongside EventSpec, EventVar, and EventCallback values. When a chain contains FunctionVars, keep backend events grouped through addEvents(...) and invoke frontend functions inline with the trigger arguments so mixed chains preserve execution order and DOM event actions like preventDefault and stopPropagation. Wrap inline arrow functions before emitting VarOperationCall JS so direct invocation renders valid JavaScript, add unit coverage for pure/mixed event-chain formatting and creation, and move upload exception docs to the helper that actually raises them to satisfy darglint. --- reflex/app.py | 10 ++-- reflex/event.py | 87 ++++++++++++++++++++++++++++---- reflex/vars/function.py | 6 ++- tests/units/test_event.py | 41 +++++++++++++++ tests/units/utils/test_format.py | 83 ++++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 17 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index 54682543a7d..5022831662c 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1931,11 +1931,6 @@ async def upload_file(request: Request): Returns: StreamingResponse yielding newline-delimited JSON of StateUpdate emitted by the upload handler. - - Raises: - UploadValueError: if there are no args with supported annotation. - UploadTypeError: if a background task is used as the handler. - HTTPException: when the request does not include token / handler headers. """ from reflex.utils.exceptions import UploadTypeError, UploadValueError @@ -1960,6 +1955,11 @@ async def _create_upload_event() -> Event: Returns: The upload event backed by the original temp files. + + Raises: + UploadValueError: If there are no uploaded files or supported annotations. + UploadTypeError: If a background task is used as the handler. + HTTPException: If the request is missing token or handler headers. """ files = form_data.getlist("files") if not files: diff --git a/reflex/event.py b/reflex/event.py index ff75e3bd3cb..2a7371f45b0 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -449,8 +449,8 @@ def __call__(self, *args, **kwargs) -> EventSpec: class EventChain(EventActionsMixin): """Container for a chain of events that will be executed in order.""" - events: Sequence["EventSpec | EventVar | EventCallback"] = dataclasses.field( - default_factory=list + events: Sequence["EventSpec | EventVar | FunctionVar | EventCallback"] = ( + dataclasses.field(default_factory=list) ) args_spec: Callable | Sequence[Callable] | None = dataclasses.field(default=None) @@ -483,6 +483,8 @@ def create( if isinstance(value, Var): if isinstance(value, EventChainVar): return value + if isinstance(value, FunctionVar): + return value if isinstance(value, EventVar): value = [value] elif safe_issubclass(value._var_type, (EventChain, EventSpec)): @@ -505,23 +507,26 @@ def create( # If the input is a list of event handlers, create an event chain. if isinstance(value, list): - events: list[EventSpec | EventVar] = [] + events: list[EventSpec | EventVar | FunctionVar] = [] for v in value: if isinstance(v, (EventHandler, EventSpec)): # Call the event handler to get the event. events.append(call_event_handler(v, args_spec, key=key)) + elif isinstance(v, (EventVar, FunctionVar)): + events.append(v) elif isinstance(v, Callable): # Call the lambda to get the event chain. result = call_event_fn(v, args_spec, key=key) if isinstance(result, Var): + if isinstance(result, (EventVar, FunctionVar)): + events.append(result) + continue msg = ( f"Invalid event chain: {v}. Cannot use a Var-returning " "lambda inside an EventChain list." ) raise ValueError(msg) events.extend(result) - elif isinstance(v, EventVar): - events.append(v) else: msg = f"Invalid event: {v}" raise ValueError(msg) @@ -2077,12 +2082,15 @@ def create( sig = inspect.signature(arg_spec) # pyright: ignore [reportArgumentType] if sig.parameters: arg_def = tuple(f"_{p}" for p in sig.parameters) - arg_def_expr = LiteralVar.create([Var(_js_expr=arg) for arg in arg_def]) + arg_vars = tuple(Var(_js_expr=arg) for arg in arg_def) + arg_def_expr = LiteralVar.create(list(arg_vars)) + call_args = arg_vars else: # add a default argument for addEvents if none were specified in value.args_spec # used to trigger the preventDefault() on the event. arg_def = ("...args",) arg_def_expr = Var(_js_expr="args") + call_args = (Var(_js_expr="...args"),) if value.invocation is None: invocation = FunctionStringVar.create( @@ -2099,16 +2107,73 @@ def create( msg = f"EventChain invocation must be a FunctionVar, got {invocation!s} of type {invocation._var_type!s}." raise ValueError(msg) + has_function_var = any(isinstance(e, FunctionVar) for e in value.events) + + if not has_function_var: + return_expr = invocation.call( + LiteralVar.create([LiteralVar.create(event) for event in value.events]), + arg_def_expr, + value.event_actions, + ) + else: + statement_js: list[str] = [] + statement_var_data: list[VarData | None] = [] + queueable_group: list[EventSpec | EventVar | EventCallback] = [] + + if value.event_actions.get("preventDefault") or value.event_actions.get( + "stopPropagation" + ): + statement_js.append( + "const _reflex_dom_event = " + f"{arg_def_expr}.filter((o) => o?.preventDefault !== undefined)[0];" + ) + if value.event_actions.get("preventDefault"): + statement_js.append( + "if (_reflex_dom_event?.preventDefault) " + "{_reflex_dom_event.preventDefault();}" + ) + if value.event_actions.get("stopPropagation"): + statement_js.append( + "if (_reflex_dom_event?.stopPropagation) " + "{_reflex_dom_event.stopPropagation();}" + ) + + def flush_queueable_group() -> None: + if not queueable_group: + return + queue_call = invocation.call( + LiteralVar.create([ + LiteralVar.create(event) for event in queueable_group + ]), + arg_def_expr, + {}, + ) + statement_js.append(f"{queue_call!s};") + statement_var_data.append(queue_call._get_all_var_data()) + queueable_group.clear() + + for event in value.events: + if isinstance(event, FunctionVar): + flush_queueable_group() + function_call = event.call(*call_args) + statement_js.append(f"{function_call!s};") + statement_var_data.append(function_call._get_all_var_data()) + else: + queueable_group.append(event) + + flush_queueable_group() + + return_expr = Var( + _js_expr=f"{{{''.join(statement_js)}}}", + _var_data=VarData.merge(*statement_var_data), + ) + return cls( _js_expr="", _var_type=EventChain, _var_data=_var_data, _args=FunctionArgs(arg_def), - _return_expr=invocation.call( - LiteralVar.create([LiteralVar.create(event) for event in value.events]), - arg_def_expr, - value.event_actions, - ), + _return_expr=return_expr, _var_value=value, ) diff --git a/reflex/vars/function.py b/reflex/vars/function.py index 6836e9d3b49..54f4b601b39 100644 --- a/reflex/vars/function.py +++ b/reflex/vars/function.py @@ -239,7 +239,11 @@ def _cached_var_name(self) -> str: Returns: The name of the var. """ - return f"({self._func!s}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))" + func_expr = str(self._func) + if "=>" in func_expr and not format.is_wrapped(func_expr, "("): + func_expr = format.wrap(func_expr, "(") + + return f"({func_expr}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))" @cached_property_no_lock def _cached_get_all_var_data(self) -> VarData | None: diff --git a/tests/units/test_event.py b/tests/units/test_event.py index c413a1f225e..020a7280082 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -32,6 +32,12 @@ def make_var(value) -> Var: return Var(_js_expr=value) +def make_timeout_logger(): + return rx.vars.FunctionStringVar.create( + "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" + ).to(EventChain) + + def test_create_event(): """Test creating an event.""" event = Event(token="token", name="state.do_thing", payload={"arg": "value"}) @@ -668,6 +674,41 @@ def _args_spec() -> tuple: assert "to bool" in str(err.value) +def test_event_chain_create_allows_plain_function_var(): + """Plain FunctionVars should be usable as frontend event handlers.""" + frontend_handler = make_timeout_logger() + + assert EventChain.create(frontend_handler, args_spec=lambda: ()) is frontend_handler + + +def test_event_chain_create_allows_function_var_in_list(): + """FunctionVars should be allowed inside EventChain lists.""" + frontend_handler = make_timeout_logger() + + chain = EventChain.create([frontend_handler], args_spec=lambda: ()) + + assert isinstance(chain, EventChain) + assert chain.events == [frontend_handler] + + +def test_button_accepts_mixed_event_handler_and_function_var(): + """Components should accept mixed backend/frontend event chains.""" + + class MixedState(BaseState): + @event + def do_a_thing(self): + pass + + log_after_timeout = make_timeout_logger() + + button = rx.button( + "Do both", + on_click=[MixedState.do_a_thing, log_after_timeout], + ) + + assert isinstance(button.event_triggers["on_click"], EventChain) + + def test_decentralized_event_with_args(): """Test the decentralized event.""" diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index c8f44f40236..c1199b44af4 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -20,6 +20,7 @@ from reflex.utils import format from reflex.utils.serializers import serialize_figure from reflex.vars.base import LiteralVar, Var +from reflex.vars.function import FunctionStringVar from reflex.vars.object import ObjectVar pytest.importorskip("pydantic") @@ -41,6 +42,88 @@ def mock_event(arg): pass +def mock_event_two(arg): + pass + + +def make_timeout_logger(): + return FunctionStringVar.create( + "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" + ).to(EventChain) + + +def test_format_prop_event_chain_pure_eventspec_grouped(): + """Pure EventSpec chains should still collapse to one addEvents call.""" + chain = EventChain( + events=[ + EventSpec(handler=EventHandler(fn=mock_event)), + EventSpec(handler=EventHandler(fn=mock_event_two)), + ], + args_spec=lambda e: [e], + ) + + assert format.format_prop(LiteralVar.create(chain)) == ( + '((_e) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ }))), ' + '(ReflexEvent("mock_event_two", ({ }), ({ })))], [_e], ({ }))))' + ) + + +def test_format_prop_event_chain_pure_function_var(): + """Pure FunctionVar chains should render as direct frontend calls.""" + log_after_timeout = make_timeout_logger() + chain = EventChain( + events=[log_after_timeout], + args_spec=lambda e: [e], + ) + + assert format.format_prop(LiteralVar.create(chain)) == ( + "((_e) => {(((...args) => { setTimeout(() => console.log('Timeout reached!', " + "args), 1000); })(_e));})" + ) + + +def test_format_prop_event_chain_mixed_queue_and_function(): + """Mixed chains should alternate addEvents and direct calls in order.""" + log_after_timeout = make_timeout_logger() + chain = EventChain( + events=[ + EventSpec(handler=EventHandler(fn=mock_event)), + log_after_timeout, + EventSpec(handler=EventHandler(fn=mock_event_two)), + ], + args_spec=lambda e: [e], + ) + + assert format.format_prop(LiteralVar.create(chain)) == ( + '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' + "[_e], ({ })));(((...args) => { setTimeout(() => console.log('Timeout reached!', " + 'args), 1000); })(_e));(addEvents([(ReflexEvent("mock_event_two", ' + "({ }), ({ })))], [_e], ({ })));})" + ) + + +def test_format_prop_event_chain_mixed_with_event_actions(): + """Mixed chains should preserve DOM event actions on the wrapper callback.""" + log_after_timeout = make_timeout_logger() + chain = EventChain( + events=[ + EventSpec(handler=EventHandler(fn=mock_event)), + log_after_timeout, + ], + args_spec=lambda e: [e], + event_actions={"preventDefault": True, "stopPropagation": True}, + ) + + assert format.format_prop(LiteralVar.create(chain)) == ( + "((_e) => {const _reflex_dom_event = [_e].filter((o) => " + "o?.preventDefault !== undefined)[0];if (_reflex_dom_event?.preventDefault) " + "{_reflex_dom_event.preventDefault();}if (_reflex_dom_event?.stopPropagation) " + '{_reflex_dom_event.stopPropagation();}(addEvents([(ReflexEvent("mock_event", ' + "({ }), ({ })))], [_e], ({ })));(((...args) => { setTimeout(() => " + "console.log('Timeout reached!', args), 1000); })(_e));})" + ) + + @pytest.mark.parametrize( ("input", "output"), [ From e75563df7b295638ff47f69dd12037351c3908c0 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 18 Mar 2026 19:23:51 +0500 Subject: [PATCH 02/15] fix: forward non-DOM event actions to queued events and improve arrow function detection Event actions like `throttle` were dropped when rendering mixed EventChains containing both backend handlers and FunctionVars. Non-DOM actions are now correctly forwarded to the queueEvents call. Also fixes false-positive arrow function detection for expressions like `factory(() => 1)` that contain `=>` but are not themselves arrow functions, and adds warnings when event_chain_kwargs are silently ignored for EventChainVar/FunctionVar values. --- reflex/event.py | 22 ++++++++- reflex/vars/function.py | 82 +++++++++++++++++++++++++++++++- tests/units/test_event.py | 30 ++++++++++++ tests/units/test_var.py | 14 ++++++ tests/units/utils/test_format.py | 21 ++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 2a7371f45b0..1969654f8ee 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -4,6 +4,7 @@ import inspect import sys import types +import warnings from base64 import b64encode from collections.abc import Callable, Mapping, Sequence from functools import lru_cache, partial @@ -90,6 +91,7 @@ def substate_token(self) -> str: BACKGROUND_TASK_MARKER = "_reflex_background_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" +DOM_EVENT_ACTIONS = frozenset({"preventDefault", "stopPropagation"}) @dataclasses.dataclass( @@ -482,8 +484,20 @@ def create( # If it's an event chain var, return it. if isinstance(value, Var): if isinstance(value, EventChainVar): + if event_chain_kwargs: + warnings.warn( + f"event_chain_kwargs {event_chain_kwargs!r} are ignored for " + "EventChainVar values.", + stacklevel=2, + ) return value if isinstance(value, FunctionVar): + if event_chain_kwargs: + warnings.warn( + f"event_chain_kwargs {event_chain_kwargs!r} are ignored for " + "FunctionVar values.", + stacklevel=2, + ) return value if isinstance(value, EventVar): value = [value] @@ -2106,6 +2120,7 @@ def create( if invocation is not None and not isinstance(invocation, FunctionVar): msg = f"EventChain invocation must be a FunctionVar, got {invocation!s} of type {invocation._var_type!s}." raise ValueError(msg) + assert invocation is not None has_function_var = any(isinstance(e, FunctionVar) for e in value.events) @@ -2119,6 +2134,11 @@ def create( statement_js: list[str] = [] statement_var_data: list[VarData | None] = [] queueable_group: list[EventSpec | EventVar | EventCallback] = [] + queueable_event_actions = { + key: action + for key, action in value.event_actions.items() + if key not in DOM_EVENT_ACTIONS + } if value.event_actions.get("preventDefault") or value.event_actions.get( "stopPropagation" @@ -2146,7 +2166,7 @@ def flush_queueable_group() -> None: LiteralVar.create(event) for event in queueable_group ]), arg_def_expr, - {}, + queueable_event_actions, ) statement_js.append(f"{queue_call!s};") statement_var_data.append(queue_call._get_all_var_data()) diff --git a/reflex/vars/function.py b/reflex/vars/function.py index 54f4b601b39..d27c9852a95 100644 --- a/reflex/vars/function.py +++ b/reflex/vars/function.py @@ -33,6 +33,84 @@ class ReflexCallable(Protocol[P, R]): ) +def _is_js_identifier_start(char: str) -> bool: + """Check whether a character can start a JavaScript identifier. + + Returns: + True if the character is valid as the first character of a JS identifier. + """ + return char == "$" or char == "_" or char.isalpha() + + +def _is_js_identifier_char(char: str) -> bool: + """Check whether a character can continue a JavaScript identifier. + + Returns: + True if the character is valid within a JS identifier. + """ + return _is_js_identifier_start(char) or char.isdigit() + + +def _starts_with_arrow_function(expr: str) -> bool: + """Check whether an expression starts with an inline arrow function. + + Returns: + True if the expression begins with an arrow function. + """ + if "=>" not in expr: + return False + + expr = expr.lstrip() + if not expr: + return False + + if expr.startswith("async"): + async_remainder = expr[len("async") :] + if async_remainder[:1].isspace(): + expr = async_remainder.lstrip() + + if not expr: + return False + + if _is_js_identifier_start(expr[0]): + end_index = 1 + while end_index < len(expr) and _is_js_identifier_char(expr[end_index]): + end_index += 1 + return expr[end_index:].lstrip().startswith("=>") + + if not expr.startswith("("): + return False + + depth = 0 + string_delimiter: str | None = None + escaped = False + + for index, char in enumerate(expr): + if string_delimiter is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == string_delimiter: + string_delimiter = None + continue + + if char in {"'", '"', "`"}: + string_delimiter = char + continue + + if char == "(": + depth += 1 + continue + + if char == ")": + depth -= 1 + if depth == 0: + return expr[index + 1 :].lstrip().startswith("=>") + + return False + + class FunctionVar(Var[CALLABLE_TYPE], default_type=ReflexCallable[Any, Any]): """Base class for immutable function vars.""" @@ -240,7 +318,9 @@ def _cached_var_name(self) -> str: The name of the var. """ func_expr = str(self._func) - if "=>" in func_expr and not format.is_wrapped(func_expr, "("): + if _starts_with_arrow_function(func_expr) and not format.is_wrapped( + func_expr, "(" + ): func_expr = format.wrap(func_expr, "(") return f"({func_expr}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))" diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 020a7280082..c02a1a69e16 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -681,6 +681,36 @@ def test_event_chain_create_allows_plain_function_var(): assert EventChain.create(frontend_handler, args_spec=lambda: ()) is frontend_handler +def test_event_chain_create_warns_for_plain_function_var_kwargs(): + """FunctionVar kwargs should warn when EventChain wrapping is bypassed.""" + frontend_handler = rx.vars.FunctionStringVar.create( + "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" + ) + + with pytest.warns(UserWarning, match="ignored for FunctionVar values"): + result = EventChain.create( + frontend_handler, + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + + assert result is frontend_handler + + +def test_event_chain_create_warns_for_event_chain_var_kwargs(): + """Prebuilt EventChainVars should also warn when extra kwargs are ignored.""" + frontend_handler = make_timeout_logger() + + with pytest.warns(UserWarning, match="ignored for EventChainVar values"): + result = EventChain.create( + frontend_handler, + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + + assert result is frontend_handler + + def test_event_chain_create_allows_function_var_in_list(): """FunctionVars should be allowed inside EventChain lists.""" frontend_handler = make_timeout_logger() diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 402505c0543..4cc9c7a159d 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -951,6 +951,20 @@ def test_function_var(): ) assert str(explicit_return_func.call(1, 2)) == "(((a, b) => {return a + b})(1, 2))" + unwrapped_arrow_func = FunctionStringVar.create( + "(...args) => { const f = x => x + 1; return f(args); }" + ) + assert ( + str(unwrapped_arrow_func.call(1)) + == "(((...args) => { const f = x => x + 1; return f(args); })(1))" + ) + + nested_arrow_expr = FunctionStringVar.create("factory(() => 1)") + assert str(nested_arrow_expr.call()) == "(factory(() => 1)())" + + string_arrow_expr = FunctionStringVar.create('factory("=>")') + assert str(string_arrow_expr.call()) == '(factory("=>")())' + def test_var_operation(): @var_operation diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index c1199b44af4..d6cfa8f8673 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -124,6 +124,27 @@ def test_format_prop_event_chain_mixed_with_event_actions(): ) +def test_format_prop_event_chain_mixed_with_queueable_event_actions(): + """Mixed chains should forward non-DOM event actions to queued backend groups.""" + log_after_timeout = make_timeout_logger() + chain = EventChain( + events=[ + EventSpec(handler=EventHandler(fn=mock_event)), + log_after_timeout, + ], + args_spec=lambda e: [e], + event_actions={"preventDefault": True, "throttle": 250}, + ) + + assert format.format_prop(LiteralVar.create(chain)) == ( + "((_e) => {const _reflex_dom_event = [_e].filter((o) => " + "o?.preventDefault !== undefined)[0];if (_reflex_dom_event?.preventDefault) " + '{_reflex_dom_event.preventDefault();}(addEvents([(ReflexEvent("mock_event", ' + '({ }), ({ })))], [_e], ({ ["throttle"] : 250 })));(((...args) => ' + "{ setTimeout(() => console.log('Timeout reached!', args), 1000); })(_e));})" + ) + + @pytest.mark.parametrize( ("input", "output"), [ From 7d3c191d28e55a69d591d8d2bb646797d6f79bc5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 18 Mar 2026 23:30:47 +0500 Subject: [PATCH 03/15] refactor: extract applyEventActions helper and simplify EventChain rendering Move event action handling (preventDefault, stopPropagation, throttle, debounce, temporal) into a shared applyEventActions JS function used by both addEvents and Python-generated event chains. This eliminates the complex queueable-group batching logic in LiteralEventChainVar and the arrow function detection heuristic in FunctionVar. --- reflex/.templates/web/utils/state.js | 96 +++++++++++++++---------- reflex/constants/compiler.py | 7 +- reflex/event.py | 103 ++++++++++----------------- reflex/vars/function.py | 82 +-------------------- tests/units/test_app.py | 80 +++++++++++++++------ tests/units/test_event.py | 50 +++++++------ tests/units/test_var.py | 6 +- tests/units/utils/test_format.py | 44 ++++++------ 8 files changed, 212 insertions(+), 256 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..867ca2d924f 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -726,6 +726,53 @@ export const ReflexEvent = ( return { name, payload, handler, event_actions }; }; +/** + * Apply event actions before invoking a target function. + * @param {Function} target The function to invoke after applying event actions. + * @param {Object.} event_actions The actions to apply. + * @param {Array|any} args The event args. + * @param {string|null} action_key A stable key for debounce/throttle tracking. + * @param {Function|null} temporal_handler Returns whether temporal actions may run. + * @returns The target result, if it runs immediately. + */ +export const applyEventActions = ( + target, + event_actions = {}, + args = [], + action_key = null, + temporal_handler = null, +) => { + if (!(args instanceof Array)) { + args = [args]; + } + + const _e = args.find((o) => o?.preventDefault !== undefined); + + if (event_actions?.preventDefault && _e?.preventDefault) { + _e.preventDefault(); + } + if (event_actions?.stopPropagation && _e?.stopPropagation) { + _e.stopPropagation(); + } + if (event_actions?.temporal && temporal_handler && !temporal_handler()) { + return; + } + + const invokeTarget = () => target(...args); + const resolved_action_key = action_key ?? target.toString(); + + if (event_actions?.throttle) { + if (!throttle(resolved_action_key, event_actions.throttle)) { + return; + } + } + if (event_actions?.debounce) { + debounce(resolved_action_key, invokeTarget, event_actions.debounce); + return; + } + return invokeTarget(); +}; + /** * Package client-side storage values as payload to send to the * backend with the hydrate event @@ -898,51 +945,24 @@ export const useEventLoop = ( // Function to add new events to the event queue. const addEvents = useCallback((events, args, event_actions) => { const _events = events.filter((e) => e !== undefined && e !== null); - if (!event_actions?.temporal) { - // Reconnect socket if needed for non-temporal events. - ensureSocketConnected(); - } - - if (!(args instanceof Array)) { - args = [args]; - } event_actions = _events.reduce( (acc, e) => ({ ...acc, ...e.event_actions }), event_actions ?? {}, ); - const _e = args.filter((o) => o?.preventDefault !== undefined)[0]; - - if (event_actions?.preventDefault && _e?.preventDefault) { - _e.preventDefault(); - } - if (event_actions?.stopPropagation && _e?.stopPropagation) { - _e.stopPropagation(); - } - const combined_name = _events.map((e) => e.name).join("+++"); - if (event_actions?.temporal) { - if (!socket.current || !socket.current.connected) { - return; // don't queue when the backend is not connected - } - } - if (event_actions?.throttle) { - // If throttle returns false, the events are not added to the queue. - if (!throttle(combined_name, event_actions.throttle)) { - return; - } - } - if (event_actions?.debounce) { - // If debounce is used, queue the events after some delay - debounce( - combined_name, - () => - queueEvents(_events, socket, false, navigate, () => params.current), - event_actions.debounce, - ); - } else { - queueEvents(_events, socket, false, navigate, () => params.current); + if (!event_actions?.temporal) { + // Reconnect socket if needed for non-temporal events. + ensureSocketConnected(); } + + return applyEventActions( + () => queueEvents(_events, socket, false, navigate, () => params.current), + event_actions, + args, + _events.map((e) => e.name).join("+++"), + () => !!socket.current?.connected, + ); }, []); const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode diff --git a/reflex/constants/compiler.py b/reflex/constants/compiler.py index 873cce69a14..0926e57f320 100644 --- a/reflex/constants/compiler.py +++ b/reflex/constants/compiler.py @@ -61,6 +61,8 @@ class CompileVars(SimpleNamespace): IS_HYDRATED = "is_hydrated" # The name of the function to add events to the queue. ADD_EVENTS = "addEvents" + # The name of the function to apply event actions before invoking a target. + APPLY_EVENT_ACTIONS = "applyEventActions" # The name of the var storing any connection error. CONNECT_ERROR = "connectErrors" # The name of the function for converting a dict to an event. @@ -128,7 +130,10 @@ class Imports(SimpleNamespace): EVENTS = { "react": [ImportVar(tag="useContext")], f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")], - f"$/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)], + f"$/{Dirs.STATE_PATH}": [ + ImportVar(tag=CompileVars.TO_EVENT), + ImportVar(tag=CompileVars.APPLY_EVENT_ACTIONS), + ], } diff --git a/reflex/event.py b/reflex/event.py index 1969654f8ee..3abc7048c2a 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -91,7 +91,6 @@ def substate_token(self) -> str: BACKGROUND_TASK_MARKER = "_reflex_background_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" -DOM_EVENT_ACTIONS = frozenset({"preventDefault", "stopPropagation"}) @dataclasses.dataclass( @@ -483,7 +482,7 @@ def create( """ # If it's an event chain var, return it. if isinstance(value, Var): - if isinstance(value, EventChainVar): + if isinstance(value, LiteralEventChainVar): if event_chain_kwargs: warnings.warn( f"event_chain_kwargs {event_chain_kwargs!r} are ignored for " @@ -491,16 +490,16 @@ def create( stacklevel=2, ) return value - if isinstance(value, FunctionVar): + if isinstance(value, (EventVar, FunctionVar)): + value = [value] + elif isinstance(value, EventChainVar): if event_chain_kwargs: warnings.warn( f"event_chain_kwargs {event_chain_kwargs!r} are ignored for " - "FunctionVar values.", + "EventChainVar values.", stacklevel=2, ) return value - if isinstance(value, EventVar): - value = [value] elif safe_issubclass(value._var_type, (EventChain, EventSpec)): return cls.create( value=value.guess_type(), @@ -2122,71 +2121,45 @@ def create( raise ValueError(msg) assert invocation is not None - has_function_var = any(isinstance(e, FunctionVar) for e in value.events) - - if not has_function_var: - return_expr = invocation.call( - LiteralVar.create([LiteralVar.create(event) for event in value.events]), - arg_def_expr, - value.event_actions, - ) - else: - statement_js: list[str] = [] - statement_var_data: list[VarData | None] = [] - queueable_group: list[EventSpec | EventVar | EventCallback] = [] - queueable_event_actions = { - key: action - for key, action in value.event_actions.items() - if key not in DOM_EVENT_ACTIONS - } - - if value.event_actions.get("preventDefault") or value.event_actions.get( - "stopPropagation" - ): - statement_js.append( - "const _reflex_dom_event = " - f"{arg_def_expr}.filter((o) => o?.preventDefault !== undefined)[0];" - ) - if value.event_actions.get("preventDefault"): - statement_js.append( - "if (_reflex_dom_event?.preventDefault) " - "{_reflex_dom_event.preventDefault();}" - ) - if value.event_actions.get("stopPropagation"): - statement_js.append( - "if (_reflex_dom_event?.stopPropagation) " - "{_reflex_dom_event.stopPropagation();}" - ) - - def flush_queueable_group() -> None: - if not queueable_group: - return - queue_call = invocation.call( - LiteralVar.create([ - LiteralVar.create(event) for event in queueable_group - ]), + statements = [ + ( + event.call(*call_args) + if isinstance(event, FunctionVar) + else invocation.call( + LiteralVar.create([LiteralVar.create(event)]), arg_def_expr, - queueable_event_actions, + {}, ) - statement_js.append(f"{queue_call!s};") - statement_var_data.append(queue_call._get_all_var_data()) - queueable_group.clear() - - for event in value.events: - if isinstance(event, FunctionVar): - flush_queueable_group() - function_call = event.call(*call_args) - statement_js.append(f"{function_call!s};") - statement_var_data.append(function_call._get_all_var_data()) - else: - queueable_group.append(event) + ) + for event in value.events + ] - flush_queueable_group() + if not statements: + statements.append(invocation.call(LiteralVar.create([]), arg_def_expr, {})) - return_expr = Var( - _js_expr=f"{{{''.join(statement_js)}}}", + statement_var_data = [statement._get_all_var_data() for statement in statements] + if len(statements) == 1 and not value.event_actions: + return_expr = statements[0] + else: + statement_block = Var( + _js_expr=f"{{{''.join(f'{statement!s};' for statement in statements)}}}", _var_data=VarData.merge(*statement_var_data), ) + if value.event_actions: + apply_event_actions = FunctionStringVar.create( + CompileVars.APPLY_EVENT_ACTIONS, + _var_data=VarData( + imports=Imports.EVENTS, + hooks={Hooks.EVENTS: None}, + ), + ) + return_expr = apply_event_actions.call( + ArgsFunctionOperation.create((), statement_block), + value.event_actions, + *call_args, + ) + else: + return_expr = statement_block return cls( _js_expr="", diff --git a/reflex/vars/function.py b/reflex/vars/function.py index d27c9852a95..29de1793ab3 100644 --- a/reflex/vars/function.py +++ b/reflex/vars/function.py @@ -33,84 +33,6 @@ class ReflexCallable(Protocol[P, R]): ) -def _is_js_identifier_start(char: str) -> bool: - """Check whether a character can start a JavaScript identifier. - - Returns: - True if the character is valid as the first character of a JS identifier. - """ - return char == "$" or char == "_" or char.isalpha() - - -def _is_js_identifier_char(char: str) -> bool: - """Check whether a character can continue a JavaScript identifier. - - Returns: - True if the character is valid within a JS identifier. - """ - return _is_js_identifier_start(char) or char.isdigit() - - -def _starts_with_arrow_function(expr: str) -> bool: - """Check whether an expression starts with an inline arrow function. - - Returns: - True if the expression begins with an arrow function. - """ - if "=>" not in expr: - return False - - expr = expr.lstrip() - if not expr: - return False - - if expr.startswith("async"): - async_remainder = expr[len("async") :] - if async_remainder[:1].isspace(): - expr = async_remainder.lstrip() - - if not expr: - return False - - if _is_js_identifier_start(expr[0]): - end_index = 1 - while end_index < len(expr) and _is_js_identifier_char(expr[end_index]): - end_index += 1 - return expr[end_index:].lstrip().startswith("=>") - - if not expr.startswith("("): - return False - - depth = 0 - string_delimiter: str | None = None - escaped = False - - for index, char in enumerate(expr): - if string_delimiter is not None: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == string_delimiter: - string_delimiter = None - continue - - if char in {"'", '"', "`"}: - string_delimiter = char - continue - - if char == "(": - depth += 1 - continue - - if char == ")": - depth -= 1 - if depth == 0: - return expr[index + 1 :].lstrip().startswith("=>") - - return False - - class FunctionVar(Var[CALLABLE_TYPE], default_type=ReflexCallable[Any, Any]): """Base class for immutable function vars.""" @@ -318,9 +240,7 @@ def _cached_var_name(self) -> str: The name of the var. """ func_expr = str(self._func) - if _starts_with_arrow_function(func_expr) and not format.is_wrapped( - func_expr, "(" - ): + if not format.is_wrapped(func_expr, "("): func_expr = format.wrap(func_expr, "(") return f"({func_expr}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 25c71c0d17e..1d7de2d5561 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1719,23 +1719,40 @@ def test_app_wrap_compile_theme( "export function Layout" ) ].strip() + + def _wrap_event_handler_calls(handler_js: str) -> str: + return ( + handler_js + .replace("jsx(", "(jsx)(") + .replace("addEvents(", "(addEvents)(") + .replace("ReflexEvent(", "(ReflexEvent)(") + .replace( + 'navigator?.["clipboard"]?.["writeText"](', + '(navigator?.["clipboard"]?.["writeText"])(', + ) + ) + expected = ( "function AppWrap({children}) {\n" "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" "return (" + ("jsx(StrictMode,{}," if react_strict_mode else "") + "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], ({ }))))""" - "}," - "jsx(RadixThemesColorModeProvider,{}," - "jsx(Fragment,{}," - "jsx(MemoizedToastProvider,{},)," - "jsx(RadixThemesTheme,{accentColor:\"plum\",css:{...theme.styles.global[':root'], ...theme.styles.global.body}}," - "jsx(Fragment,{}," - "jsx(DefaultOverlayComponents,{},)," - "jsx(Fragment,{}," - "children" + + _wrap_event_handler_calls( + """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"))))))))))))),""" + ) + + _wrap_event_handler_calls( + """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], ({ }))))""" + ) + + "}," + + "jsx(RadixThemesColorModeProvider,{}," + + "jsx(Fragment,{}," + + "jsx(MemoizedToastProvider,{},)," + + "jsx(RadixThemesTheme,{accentColor:\"plum\",css:{...theme.styles.global[':root'], ...theme.styles.global.body}}," + + "jsx(Fragment,{}," + + "jsx(DefaultOverlayComponents,{},)," + + "jsx(Fragment,{}," + + "children" "))))))" + (")" if react_strict_mode else "") + ")" "\n}" ) @@ -1792,6 +1809,19 @@ def page(): "export function Layout" ) ].strip() + + def _wrap_event_handler_calls(handler_js: str) -> str: + return ( + handler_js + .replace("jsx(", "(jsx)(") + .replace("addEvents(", "(addEvents)(") + .replace("ReflexEvent(", "(ReflexEvent)(") + .replace( + 'navigator?.["clipboard"]?.["writeText"](', + '(navigator?.["clipboard"]?.["writeText"])(', + ) + ) + expected = ( "function AppWrap({children}) {\n" "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" @@ -1799,18 +1829,22 @@ def page(): + ("jsx(StrictMode,{}," if react_strict_mode else "") + "jsx(RadixThemesBox,{}," "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], ({ }))))""" - "}," - 'jsx(RadixThemesText,{as:"p"},' - "jsx(RadixThemesColorModeProvider,{}," - "jsx(Fragment,{}," - "jsx(MemoizedToastProvider,{},)," - "jsx(Fragment2,{}," - "jsx(Fragment,{}," - "jsx(DefaultOverlayComponents,{},)," - "jsx(Fragment,{}," - "children" + + _wrap_event_handler_calls( + """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"))))))))))))),""" + ) + + _wrap_event_handler_calls( + """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], ({ }))))""" + ) + + "}," + + 'jsx(RadixThemesText,{as:"p"},' + + "jsx(RadixThemesColorModeProvider,{}," + + "jsx(Fragment,{}," + + "jsx(MemoizedToastProvider,{},)," + + "jsx(Fragment2,{}," + + "jsx(Fragment,{}," + + "jsx(DefaultOverlayComponents,{},)," + + "jsx(Fragment,{}," + + "children" ")))))))" + (")" 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 c02a1a69e16..6a783e18d5f 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -213,21 +213,21 @@ def test_event_console_log(): assert spec.handler.fn.__qualname__ == "_call_function" assert spec.args[0][0].equals(Var(_js_expr="function")) assert spec.args[0][1].equals( - Var('(() => (console?.["log"]("message")))', _var_type=Callable) + Var('(() => ((console?.["log"])("message")))', _var_type=Callable) ) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => (console?.["log"]("message"))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])("message"))),callback:null})' ) spec = event.console_log(Var(_js_expr="message")) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => (console?.["log"](message))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])(message))),callback:null})' ) spec2 = event.console_log(Var(_js_expr="message2")).add_args(Var("throwaway")) assert ( format.format_event(spec2) - == 'ReflexEvent("_call_function", {function:(() => (console?.["log"](message2))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])(message2))),callback:null})' ) @@ -238,21 +238,21 @@ def test_event_window_alert(): assert spec.handler.fn.__qualname__ == "_call_function" assert spec.args[0][0].equals(Var(_js_expr="function")) assert spec.args[0][1].equals( - Var('(() => (window?.["alert"]("message")))', _var_type=Callable) + Var('(() => ((window?.["alert"])("message")))', _var_type=Callable) ) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"]("message"))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])("message"))),callback:null})' ) spec = event.window_alert(Var(_js_expr="message")) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"](message))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])(message))),callback:null})' ) spec2 = event.window_alert(Var(_js_expr="message2")).add_args(Var("throwaway")) assert ( format.format_event(spec2) - == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"](message2))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])(message2))),callback:null})' ) @@ -676,39 +676,45 @@ def _args_spec() -> tuple: def test_event_chain_create_allows_plain_function_var(): """Plain FunctionVars should be usable as frontend event handlers.""" - frontend_handler = make_timeout_logger() + frontend_handler = rx.vars.FunctionStringVar.create( + "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" + ) - assert EventChain.create(frontend_handler, args_spec=lambda: ()) is frontend_handler + chain = EventChain.create(frontend_handler, args_spec=lambda: ()) + assert isinstance(chain, EventChain) + assert chain.events == [frontend_handler] -def test_event_chain_create_warns_for_plain_function_var_kwargs(): - """FunctionVar kwargs should warn when EventChain wrapping is bypassed.""" + +def test_event_chain_create_wraps_plain_function_var_kwargs(): + """FunctionVars should compose with chain-level kwargs instead of bypassing wrapping.""" frontend_handler = rx.vars.FunctionStringVar.create( "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" ) - with pytest.warns(UserWarning, match="ignored for FunctionVar values"): - result = EventChain.create( - frontend_handler, - args_spec=lambda: (), - event_actions={"preventDefault": True}, - ) + chain = EventChain.create( + frontend_handler, + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) - assert result is frontend_handler + assert isinstance(chain, EventChain) + assert chain.events == [frontend_handler] + assert chain.event_actions == {"preventDefault": True} def test_event_chain_create_warns_for_event_chain_var_kwargs(): """Prebuilt EventChainVars should also warn when extra kwargs are ignored.""" - frontend_handler = make_timeout_logger() + prebuilt_chain = Var.create(EventChain(events=[], args_spec=lambda: ())) with pytest.warns(UserWarning, match="ignored for EventChainVar values"): result = EventChain.create( - frontend_handler, + prebuilt_chain, args_spec=lambda: (), event_actions={"preventDefault": True}, ) - assert result is frontend_handler + assert result is prebuilt_chain def test_event_chain_create_allows_function_var_in_list(): diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 4cc9c7a159d..093e28649e5 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -358,7 +358,7 @@ def test_basic_operations(TestObj): == "state.foo.slice().reverse()" ) assert str(Var(_js_expr="foo").to(list).reverse()) == "foo.slice().reverse()" - assert str(Var(_js_expr="foo", _var_type=str).js_type()) == "(typeof(foo))" + assert str(Var(_js_expr="foo", _var_type=str).js_type()) == "((typeof)(foo))" @pytest.mark.parametrize( @@ -960,10 +960,10 @@ def test_function_var(): ) nested_arrow_expr = FunctionStringVar.create("factory(() => 1)") - assert str(nested_arrow_expr.call()) == "(factory(() => 1)())" + assert str(nested_arrow_expr.call()) == "((factory(() => 1))())" string_arrow_expr = FunctionStringVar.create('factory("=>")') - assert str(string_arrow_expr.call()) == '(factory("=>")())' + assert str(string_arrow_expr.call()) == '((factory("=>"))())' def test_var_operation(): diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index d6cfa8f8673..c28d0eff57e 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -53,7 +53,7 @@ def make_timeout_logger(): def test_format_prop_event_chain_pure_eventspec_grouped(): - """Pure EventSpec chains should still collapse to one addEvents call.""" + """Pure EventSpec chains should emit one addEvents call per event.""" chain = EventChain( events=[ EventSpec(handler=EventHandler(fn=mock_event)), @@ -63,8 +63,9 @@ def test_format_prop_event_chain_pure_eventspec_grouped(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ }))), ' - '(ReflexEvent("mock_event_two", ({ }), ({ })))], [_e], ({ }))))' + '((_e) => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], ' + '[_e], ({ })));((addEvents)([((ReflexEvent)("mock_event_two", ({ }), ' + "({ })))], [_e], ({ })));})" ) @@ -77,8 +78,8 @@ def test_format_prop_event_chain_pure_function_var(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - "((_e) => {(((...args) => { setTimeout(() => console.log('Timeout reached!', " - "args), 1000); })(_e));})" + "((_e) => (((...args) => { setTimeout(() => console.log('Timeout reached!', " + "args), 1000); })(_e)))" ) @@ -95,9 +96,9 @@ def test_format_prop_event_chain_mixed_queue_and_function(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' + '((_e) => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], ' "[_e], ({ })));(((...args) => { setTimeout(() => console.log('Timeout reached!', " - 'args), 1000); })(_e));(addEvents([(ReflexEvent("mock_event_two", ' + 'args), 1000); })(_e));((addEvents)([((ReflexEvent)("mock_event_two", ' "({ }), ({ })))], [_e], ({ })));})" ) @@ -115,12 +116,10 @@ def test_format_prop_event_chain_mixed_with_event_actions(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - "((_e) => {const _reflex_dom_event = [_e].filter((o) => " - "o?.preventDefault !== undefined)[0];if (_reflex_dom_event?.preventDefault) " - "{_reflex_dom_event.preventDefault();}if (_reflex_dom_event?.stopPropagation) " - '{_reflex_dom_event.stopPropagation();}(addEvents([(ReflexEvent("mock_event", ' + '((_e) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ' "({ }), ({ })))], [_e], ({ })));(((...args) => { setTimeout(() => " - "console.log('Timeout reached!', args), 1000); })(_e));})" + "console.log('Timeout reached!', args), 1000); })(_e));}), ({ " + '["preventDefault"] : true, ["stopPropagation"] : true }), _e)))' ) @@ -137,11 +136,10 @@ def test_format_prop_event_chain_mixed_with_queueable_event_actions(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - "((_e) => {const _reflex_dom_event = [_e].filter((o) => " - "o?.preventDefault !== undefined)[0];if (_reflex_dom_event?.preventDefault) " - '{_reflex_dom_event.preventDefault();}(addEvents([(ReflexEvent("mock_event", ' - '({ }), ({ })))], [_e], ({ ["throttle"] : 250 })));(((...args) => ' - "{ setTimeout(() => console.log('Timeout reached!', args), 1000); })(_e));})" + '((_e) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ' + "({ }), ({ })))], [_e], ({ })));(((...args) => { setTimeout(() => " + "console.log('Timeout reached!', args), 1000); })(_e));}), ({ " + '["preventDefault"] : true, ["throttle"] : 250 }), _e)))' ) @@ -484,7 +482,7 @@ def test_format_match( events=[EventSpec(handler=EventHandler(fn=mock_event))], args_spec=no_args_event_spec, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ }))))', + '((...args) => ((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ }))))', ), ( EventChain( @@ -505,7 +503,7 @@ def test_format_match( ], args_spec=lambda e: [e.target.value], ), - '((_e) => (addEvents([(ReflexEvent("mock_event", ({ ["arg"] : _e?.["target"]?.["value"] }), ({ })))], [_e], ({ }))))', + '((_e) => ((addEvents)([((ReflexEvent)("mock_event", ({ ["arg"] : _e?.["target"]?.["value"] }), ({ })))], [_e], ({ }))))', ), ( EventChain( @@ -513,7 +511,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"stopPropagation": True}, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["stopPropagation"] : true }))))', + '((...args) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["stopPropagation"] : true }), ...args)))', ), ( EventChain( @@ -525,7 +523,7 @@ def test_format_match( ], args_spec=no_args_event_spec, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ ["stopPropagation"] : true })))], args, ({ }))))', + '((...args) => ((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ ["stopPropagation"] : true })))], args, ({ }))))', ), ( EventChain( @@ -533,7 +531,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"preventDefault": True}, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["preventDefault"] : true }))))', + '((...args) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["preventDefault"] : true }), ...args)))', ), ({"a": "red", "b": "blue"}, '({ ["a"] : "red", ["b"] : "blue" })'), (Var(_js_expr="var", _var_type=int).guess_type(), "var"), @@ -641,7 +639,7 @@ def test_format_event_handler(input, output): [ ( EventSpec(handler=EventHandler(fn=mock_event)), - '(ReflexEvent("mock_event", ({ }), ({ })))', + '((ReflexEvent)("mock_event", ({ }), ({ })))', ), ], ) From 03bbbf536b643e0f76e2c87781b4b414f82be42e Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 01:11:37 +0500 Subject: [PATCH 04/15] fix: avoid wrapping JS operators as callable expressions Restore selective arrow-function wrapping in FunctionVar calls so operator-like expressions such as `typeof` keep compiling correctly. This fixes the generated JSX regression that broke form integration tests by emitting invalid code like `((typeof)(value))`. Also update unit expectations for the corrected rendering behavior. --- reflex/vars/function.py | 82 +++++++++++++++++++++++++++++++- tests/units/test_event.py | 16 +++---- tests/units/test_var.py | 6 +-- tests/units/utils/test_format.py | 24 +++++----- 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/reflex/vars/function.py b/reflex/vars/function.py index 29de1793ab3..d27c9852a95 100644 --- a/reflex/vars/function.py +++ b/reflex/vars/function.py @@ -33,6 +33,84 @@ class ReflexCallable(Protocol[P, R]): ) +def _is_js_identifier_start(char: str) -> bool: + """Check whether a character can start a JavaScript identifier. + + Returns: + True if the character is valid as the first character of a JS identifier. + """ + return char == "$" or char == "_" or char.isalpha() + + +def _is_js_identifier_char(char: str) -> bool: + """Check whether a character can continue a JavaScript identifier. + + Returns: + True if the character is valid within a JS identifier. + """ + return _is_js_identifier_start(char) or char.isdigit() + + +def _starts_with_arrow_function(expr: str) -> bool: + """Check whether an expression starts with an inline arrow function. + + Returns: + True if the expression begins with an arrow function. + """ + if "=>" not in expr: + return False + + expr = expr.lstrip() + if not expr: + return False + + if expr.startswith("async"): + async_remainder = expr[len("async") :] + if async_remainder[:1].isspace(): + expr = async_remainder.lstrip() + + if not expr: + return False + + if _is_js_identifier_start(expr[0]): + end_index = 1 + while end_index < len(expr) and _is_js_identifier_char(expr[end_index]): + end_index += 1 + return expr[end_index:].lstrip().startswith("=>") + + if not expr.startswith("("): + return False + + depth = 0 + string_delimiter: str | None = None + escaped = False + + for index, char in enumerate(expr): + if string_delimiter is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == string_delimiter: + string_delimiter = None + continue + + if char in {"'", '"', "`"}: + string_delimiter = char + continue + + if char == "(": + depth += 1 + continue + + if char == ")": + depth -= 1 + if depth == 0: + return expr[index + 1 :].lstrip().startswith("=>") + + return False + + class FunctionVar(Var[CALLABLE_TYPE], default_type=ReflexCallable[Any, Any]): """Base class for immutable function vars.""" @@ -240,7 +318,9 @@ def _cached_var_name(self) -> str: The name of the var. """ func_expr = str(self._func) - if not format.is_wrapped(func_expr, "("): + if _starts_with_arrow_function(func_expr) and not format.is_wrapped( + func_expr, "(" + ): func_expr = format.wrap(func_expr, "(") return f"({func_expr}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))" diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 6a783e18d5f..9ebdc887e27 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -213,21 +213,21 @@ def test_event_console_log(): assert spec.handler.fn.__qualname__ == "_call_function" assert spec.args[0][0].equals(Var(_js_expr="function")) assert spec.args[0][1].equals( - Var('(() => ((console?.["log"])("message")))', _var_type=Callable) + Var('(() => (console?.["log"]("message")))', _var_type=Callable) ) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])("message"))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (console?.["log"]("message"))),callback:null})' ) spec = event.console_log(Var(_js_expr="message")) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])(message))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (console?.["log"](message))),callback:null})' ) spec2 = event.console_log(Var(_js_expr="message2")).add_args(Var("throwaway")) assert ( format.format_event(spec2) - == 'ReflexEvent("_call_function", {function:(() => ((console?.["log"])(message2))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (console?.["log"](message2))),callback:null})' ) @@ -238,21 +238,21 @@ def test_event_window_alert(): assert spec.handler.fn.__qualname__ == "_call_function" assert spec.args[0][0].equals(Var(_js_expr="function")) assert spec.args[0][1].equals( - Var('(() => ((window?.["alert"])("message")))', _var_type=Callable) + Var('(() => (window?.["alert"]("message")))', _var_type=Callable) ) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])("message"))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"]("message"))),callback:null})' ) spec = event.window_alert(Var(_js_expr="message")) assert ( format.format_event(spec) - == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])(message))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"](message))),callback:null})' ) spec2 = event.window_alert(Var(_js_expr="message2")).add_args(Var("throwaway")) assert ( format.format_event(spec2) - == 'ReflexEvent("_call_function", {function:(() => ((window?.["alert"])(message2))),callback:null})' + == 'ReflexEvent("_call_function", {function:(() => (window?.["alert"](message2))),callback:null})' ) diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 093e28649e5..4cc9c7a159d 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -358,7 +358,7 @@ def test_basic_operations(TestObj): == "state.foo.slice().reverse()" ) assert str(Var(_js_expr="foo").to(list).reverse()) == "foo.slice().reverse()" - assert str(Var(_js_expr="foo", _var_type=str).js_type()) == "((typeof)(foo))" + assert str(Var(_js_expr="foo", _var_type=str).js_type()) == "(typeof(foo))" @pytest.mark.parametrize( @@ -960,10 +960,10 @@ def test_function_var(): ) nested_arrow_expr = FunctionStringVar.create("factory(() => 1)") - assert str(nested_arrow_expr.call()) == "((factory(() => 1))())" + assert str(nested_arrow_expr.call()) == "(factory(() => 1)())" string_arrow_expr = FunctionStringVar.create('factory("=>")') - assert str(string_arrow_expr.call()) == '((factory("=>"))())' + assert str(string_arrow_expr.call()) == '(factory("=>")())' def test_var_operation(): diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index c28d0eff57e..dbf6dfb127a 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -63,8 +63,8 @@ def test_format_prop_event_chain_pure_eventspec_grouped(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], ' - '[_e], ({ })));((addEvents)([((ReflexEvent)("mock_event_two", ({ }), ' + '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' + '[_e], ({ })));(addEvents([(ReflexEvent("mock_event_two", ({ }), ' "({ })))], [_e], ({ })));})" ) @@ -96,9 +96,9 @@ def test_format_prop_event_chain_mixed_queue_and_function(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], ' + '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' "[_e], ({ })));(((...args) => { setTimeout(() => console.log('Timeout reached!', " - 'args), 1000); })(_e));((addEvents)([((ReflexEvent)("mock_event_two", ' + 'args), 1000); })(_e));(addEvents([(ReflexEvent("mock_event_two", ' "({ }), ({ })))], [_e], ({ })));})" ) @@ -116,7 +116,7 @@ def test_format_prop_event_chain_mixed_with_event_actions(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ' + '((_e) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ' "({ }), ({ })))], [_e], ({ })));(((...args) => { setTimeout(() => " "console.log('Timeout reached!', args), 1000); })(_e));}), ({ " '["preventDefault"] : true, ["stopPropagation"] : true }), _e)))' @@ -136,7 +136,7 @@ def test_format_prop_event_chain_mixed_with_queueable_event_actions(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ' + '((_e) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ' "({ }), ({ })))], [_e], ({ })));(((...args) => { setTimeout(() => " "console.log('Timeout reached!', args), 1000); })(_e));}), ({ " '["preventDefault"] : true, ["throttle"] : 250 }), _e)))' @@ -482,7 +482,7 @@ def test_format_match( events=[EventSpec(handler=EventHandler(fn=mock_event))], args_spec=no_args_event_spec, ), - '((...args) => ((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ }))))', + '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ }))))', ), ( EventChain( @@ -503,7 +503,7 @@ def test_format_match( ], args_spec=lambda e: [e.target.value], ), - '((_e) => ((addEvents)([((ReflexEvent)("mock_event", ({ ["arg"] : _e?.["target"]?.["value"] }), ({ })))], [_e], ({ }))))', + '((_e) => (addEvents([(ReflexEvent("mock_event", ({ ["arg"] : _e?.["target"]?.["value"] }), ({ })))], [_e], ({ }))))', ), ( EventChain( @@ -511,7 +511,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"stopPropagation": True}, ), - '((...args) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["stopPropagation"] : true }), ...args)))', + '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["stopPropagation"] : true }), ...args)))', ), ( EventChain( @@ -523,7 +523,7 @@ def test_format_match( ], args_spec=no_args_event_spec, ), - '((...args) => ((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ ["stopPropagation"] : true })))], args, ({ }))))', + '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ ["stopPropagation"] : true })))], args, ({ }))))', ), ( EventChain( @@ -531,7 +531,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"preventDefault": True}, ), - '((...args) => ((applyEventActions)((() => {((addEvents)([((ReflexEvent)("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["preventDefault"] : true }), ...args)))', + '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["preventDefault"] : true }), ...args)))', ), ({"a": "red", "b": "blue"}, '({ ["a"] : "red", ["b"] : "blue" })'), (Var(_js_expr="var", _var_type=int).guess_type(), "var"), @@ -639,7 +639,7 @@ def test_format_event_handler(input, output): [ ( EventSpec(handler=EventHandler(fn=mock_event)), - '((ReflexEvent)("mock_event", ({ }), ({ })))', + '(ReflexEvent("mock_event", ({ }), ({ })))', ), ], ) From 84f9b934c0d23740b3ce84f40b042c35ed7cbde7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 01:31:11 +0500 Subject: [PATCH 05/15] test: fix test regression --- tests/units/test_app.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 1d7de2d5561..e8d0c7cf6c4 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1721,16 +1721,7 @@ def test_app_wrap_compile_theme( ].strip() def _wrap_event_handler_calls(handler_js: str) -> str: - return ( - handler_js - .replace("jsx(", "(jsx)(") - .replace("addEvents(", "(addEvents)(") - .replace("ReflexEvent(", "(ReflexEvent)(") - .replace( - 'navigator?.["clipboard"]?.["writeText"](', - '(navigator?.["clipboard"]?.["writeText"])(', - ) - ) + return handler_js expected = ( "function AppWrap({children}) {\n" @@ -1811,16 +1802,7 @@ def page(): ].strip() def _wrap_event_handler_calls(handler_js: str) -> str: - return ( - handler_js - .replace("jsx(", "(jsx)(") - .replace("addEvents(", "(addEvents)(") - .replace("ReflexEvent(", "(ReflexEvent)(") - .replace( - 'navigator?.["clipboard"]?.["writeText"](', - '(navigator?.["clipboard"]?.["writeText"])(', - ) - ) + return handler_js expected = ( "function AppWrap({children}) {\n" From c2380bc5ecaf9fef9c739a3067e8bf79092f2dc6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 13:41:26 +0500 Subject: [PATCH 06/15] perf: fast-path backend-only EventChains to skip applyEventActions wrapper --- reflex/event.py | 20 ++++++++++-- tests/units/test_event.py | 55 ++++++++++++++++++++++++++++++++ tests/units/utils/test_format.py | 11 +++---- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 3abc7048c2a..43896edb117 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -2093,17 +2093,16 @@ def create( else value.args_spec ) sig = inspect.signature(arg_spec) # pyright: ignore [reportArgumentType] + arg_vars = () if sig.parameters: arg_def = tuple(f"_{p}" for p in sig.parameters) arg_vars = tuple(Var(_js_expr=arg) for arg in arg_def) arg_def_expr = LiteralVar.create(list(arg_vars)) - call_args = arg_vars else: # add a default argument for addEvents if none were specified in value.args_spec # used to trigger the preventDefault() on the event. arg_def = ("...args",) arg_def_expr = Var(_js_expr="args") - call_args = (Var(_js_expr="...args"),) if value.invocation is None: invocation = FunctionStringVar.create( @@ -2121,6 +2120,23 @@ def create( raise ValueError(msg) assert invocation is not None + if not any(isinstance(event, FunctionVar) for event in value.events): + return cls( + _js_expr="", + _var_type=EventChain, + _var_data=_var_data, + _args=FunctionArgs(arg_def), + _return_expr=invocation.call( + LiteralVar.create([ + LiteralVar.create(event) for event in value.events + ]), + arg_def_expr, + value.event_actions, + ), + _var_value=value, + ) + + call_args = arg_vars if sig.parameters else (Var(_js_expr="...args"),) statements = [ ( event.call(*call_args) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 9ebdc887e27..3eb488e83bd 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -745,6 +745,61 @@ def do_a_thing(self): assert isinstance(button.event_triggers["on_click"], EventChain) +def test_event_chain_codegen_uses_fast_path_for_backend_only_events(): + """Backend-only chains should render through a single addEvents call.""" + + class FastPathState(BaseState): + @event + def do_a_thing(self): + pass + + chain = EventChain.create( + [FastPathState.do_a_thing, FastPathState.do_a_thing], + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + rendered = str(LiteralVar.create(chain)) + + assert "applyEventActions(" not in rendered + assert rendered.count("addEvents(") == 1 + assert '["preventDefault"] : true' in rendered + + +def test_event_chain_codegen_keeps_apply_event_actions_for_function_vars(): + """Frontend handlers should keep the applyEventActions wrapper.""" + log_after_timeout = make_timeout_logger() + + chain = EventChain.create( + log_after_timeout, + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + rendered = str(LiteralVar.create(chain)) + + assert "applyEventActions(" in rendered + assert "Timeout reached!" in rendered + + +def test_event_chain_codegen_preserves_mixed_chain_order(): + """Mixed chains should keep backend and frontend work in the original order.""" + + class MixedState(BaseState): + @event + def do_a_thing(self): + pass + + log_after_timeout = make_timeout_logger() + chain = EventChain.create( + [MixedState.do_a_thing, log_after_timeout], + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + rendered = str(LiteralVar.create(chain)) + + assert "applyEventActions(" in rendered + assert rendered.index("addEvents(") < rendered.index("Timeout reached!") + + def test_decentralized_event_with_args(): """Test the decentralized event.""" diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index dbf6dfb127a..328ec06714a 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -53,7 +53,7 @@ def make_timeout_logger(): def test_format_prop_event_chain_pure_eventspec_grouped(): - """Pure EventSpec chains should emit one addEvents call per event.""" + """Pure EventSpec chains should stay grouped in one addEvents call.""" chain = EventChain( events=[ EventSpec(handler=EventHandler(fn=mock_event)), @@ -63,9 +63,8 @@ def test_format_prop_event_chain_pure_eventspec_grouped(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' - '[_e], ({ })));(addEvents([(ReflexEvent("mock_event_two", ({ }), ' - "({ })))], [_e], ({ })));})" + '((_e) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ }))), ' + '(ReflexEvent("mock_event_two", ({ }), ({ })))], [_e], ({ }))))' ) @@ -511,7 +510,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"stopPropagation": True}, ), - '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["stopPropagation"] : true }), ...args)))', + '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["stopPropagation"] : true }))))', ), ( EventChain( @@ -531,7 +530,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"preventDefault": True}, ), - '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["preventDefault"] : true }), ...args)))', + '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["preventDefault"] : true }))))', ), ({"a": "red", "b": "blue"}, '({ ["a"] : "red", ["b"] : "blue" })'), (Var(_js_expr="var", _var_type=int).guess_type(), "var"), From 76cbdf761e04f8f77c0692dc380fac0b73e2e89f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 20 Mar 2026 11:53:14 +0500 Subject: [PATCH 07/15] test: simplify test --- tests/units/test_app.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index e8d0c7cf6c4..d01fe5de076 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1720,21 +1720,14 @@ def test_app_wrap_compile_theme( ) ].strip() - def _wrap_event_handler_calls(handler_js: str) -> str: - return handler_js - expected = ( "function AppWrap({children}) {\n" "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" "return (" + ("jsx(StrictMode,{}," if react_strict_mode else "") + "jsx(ErrorBoundary,{" - + _wrap_event_handler_calls( - """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"))))))))))))),""" - ) - + _wrap_event_handler_calls( - """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], ({ }))))""" - ) + """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], ({ }))))""" + "}," + "jsx(RadixThemesColorModeProvider,{}," + "jsx(Fragment,{}," @@ -1801,9 +1794,6 @@ def page(): ) ].strip() - def _wrap_event_handler_calls(handler_js: str) -> str: - return handler_js - expected = ( "function AppWrap({children}) {\n" "const [addEvents, connectErrors] = useContext(EventLoopContext);\n\n\n\n" @@ -1811,12 +1801,8 @@ def _wrap_event_handler_calls(handler_js: str) -> str: + ("jsx(StrictMode,{}," if react_strict_mode else "") + "jsx(RadixThemesBox,{}," "jsx(ErrorBoundary,{" - + _wrap_event_handler_calls( - """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"))))))))))))),""" - ) - + _wrap_event_handler_calls( - """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], ({ }))))""" - ) + """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], ({ }))))""" + "}," + 'jsx(RadixThemesText,{as:"p"},' + "jsx(RadixThemesColorModeProvider,{}," From 1d7e57e420c73264aa77a3fd560eec44bd586fcc Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 20 Mar 2026 12:45:20 +0500 Subject: [PATCH 08/15] fix: allow EventChain-typed FunctionVars to compose with event_chain_kwargs --- reflex/event.py | 11 +++-------- tests/units/test_event.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 7978ff3e38c..68101c7bbeb 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -482,6 +482,9 @@ def create( """ # If it's an event chain var, return it. if isinstance(value, Var): + # Only pass through literal/prebuilt chains. Other EventChainVar values may be + # FunctionVars cast with `.to(EventChain)` and still need wrapping so + # event_chain_kwargs can compose onto the resulting chain. if isinstance(value, LiteralEventChainVar): if event_chain_kwargs: warnings.warn( @@ -492,14 +495,6 @@ def create( return value if isinstance(value, (EventVar, FunctionVar)): value = [value] - elif isinstance(value, EventChainVar): - if event_chain_kwargs: - warnings.warn( - f"event_chain_kwargs {event_chain_kwargs!r} are ignored for " - "EventChainVar values.", - stacklevel=2, - ) - return value elif safe_issubclass(value._var_type, (EventChain, EventSpec)): return cls.create( value=value.guess_type(), diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 3eb488e83bd..1106595090a 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -703,6 +703,21 @@ def test_event_chain_create_wraps_plain_function_var_kwargs(): assert chain.event_actions == {"preventDefault": True} +def test_event_chain_create_wraps_event_chain_typed_function_var_kwargs(): + """FunctionVars cast to EventChain should still compose with chain-level kwargs.""" + frontend_handler = make_timeout_logger() + + chain = EventChain.create( + frontend_handler, + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + + assert isinstance(chain, EventChain) + assert chain.events == [frontend_handler] + assert chain.event_actions == {"preventDefault": True} + + def test_event_chain_create_warns_for_event_chain_var_kwargs(): """Prebuilt EventChainVars should also warn when extra kwargs are ignored.""" prebuilt_chain = Var.create(EventChain(events=[], args_spec=lambda: ())) From df626d0dc82ddd2ed3078b76e874ee50473552b0 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 20 Mar 2026 12:55:55 +0500 Subject: [PATCH 09/15] fix: remove EventChain fast path to preserve per-spec event actions The fast path grouped backend-only EventSpecs into a single addEvents call, which lost per-spec event actions like individual debounce values. Each EventSpec now renders its own addEvents call, and chain-level actions use applyEventActions consistently. --- reflex/event.py | 16 ------------- tests/units/test_event.py | 41 +++++++++++++++++++++++++++----- tests/units/utils/test_format.py | 11 +++++---- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 68101c7bbeb..db3e37dd67d 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -2115,22 +2115,6 @@ def create( raise ValueError(msg) assert invocation is not None - if not any(isinstance(event, FunctionVar) for event in value.events): - return cls( - _js_expr="", - _var_type=EventChain, - _var_data=_var_data, - _args=FunctionArgs(arg_def), - _return_expr=invocation.call( - LiteralVar.create([ - LiteralVar.create(event) for event in value.events - ]), - arg_def_expr, - value.event_actions, - ), - _var_value=value, - ) - call_args = arg_vars if sig.parameters else (Var(_js_expr="...args"),) statements = [ ( diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 1106595090a..a830566e80d 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -760,23 +760,52 @@ def do_a_thing(self): assert isinstance(button.event_triggers["on_click"], EventChain) -def test_event_chain_codegen_uses_fast_path_for_backend_only_events(): - """Backend-only chains should render through a single addEvents call.""" +def test_event_chain_codegen_preserves_backend_event_actions_per_spec(): + """Backend-only chains should keep per-spec event actions separate.""" class FastPathState(BaseState): @event - def do_a_thing(self): + def do_a_thing(self, value: str): pass chain = EventChain.create( - [FastPathState.do_a_thing, FastPathState.do_a_thing], + [ + FastPathState.do_a_thing("first x 1000").debounce(1000), + FastPathState.do_a_thing("second x 200").debounce(200), + ], args_spec=lambda: (), - event_actions={"preventDefault": True}, ) rendered = str(LiteralVar.create(chain)) assert "applyEventActions(" not in rendered - assert rendered.count("addEvents(") == 1 + assert rendered.count("addEvents(") == 2 + assert '["debounce"] : 1000' in rendered + assert '["debounce"] : 200' in rendered + assert rendered.index("first x 1000") < rendered.index("second x 200") + + +def test_event_chain_codegen_keeps_chain_event_actions_for_backend_only_events(): + """Chain-level actions should still wrap backend-only event chains.""" + + class FastPathState(BaseState): + @event + def do_a_thing(self, value: str): + pass + + chain = EventChain.create( + [ + FastPathState.do_a_thing("first x 1000").debounce(1000), + FastPathState.do_a_thing("second x 200").debounce(200), + ], + args_spec=lambda: (), + event_actions={"preventDefault": True}, + ) + rendered = str(LiteralVar.create(chain)) + + assert "applyEventActions(" in rendered + assert rendered.count("addEvents(") == 2 + assert '["debounce"] : 1000' in rendered + assert '["debounce"] : 200' in rendered assert '["preventDefault"] : true' in rendered diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index 328ec06714a..48db442223c 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -53,7 +53,7 @@ def make_timeout_logger(): def test_format_prop_event_chain_pure_eventspec_grouped(): - """Pure EventSpec chains should stay grouped in one addEvents call.""" + """Pure EventSpec chains should preserve order with separate addEvents calls.""" chain = EventChain( events=[ EventSpec(handler=EventHandler(fn=mock_event)), @@ -63,8 +63,9 @@ def test_format_prop_event_chain_pure_eventspec_grouped(): ) assert format.format_prop(LiteralVar.create(chain)) == ( - '((_e) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ }))), ' - '(ReflexEvent("mock_event_two", ({ }), ({ })))], [_e], ({ }))))' + '((_e) => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], ' + '[_e], ({ })));(addEvents([(ReflexEvent("mock_event_two", ({ }), ' + "({ })))], [_e], ({ })));})" ) @@ -510,7 +511,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"stopPropagation": True}, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["stopPropagation"] : true }))))', + '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["stopPropagation"] : true }), ...args)))', ), ( EventChain( @@ -530,7 +531,7 @@ def test_format_match( args_spec=no_args_event_spec, event_actions={"preventDefault": True}, ), - '((...args) => (addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ ["preventDefault"] : true }))))', + '((...args) => (applyEventActions((() => {(addEvents([(ReflexEvent("mock_event", ({ }), ({ })))], args, ({ })));}), ({ ["preventDefault"] : true }), ...args)))', ), ({"a": "red", "b": "blue"}, '({ ["a"] : "red", ["b"] : "blue" })'), (Var(_js_expr="var", _var_type=int).guess_type(), "var"), From 6fb6480a514b25dcdb49f501a51eafb58d1d7699 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 20 Mar 2026 11:42:21 -0700 Subject: [PATCH 10/15] use Var.equals for var equality checking Without this, using `==` in an assertion with Var values ends up creating a new truthy Var instead of actually checking python-side equality. Var equality assertions have to be made explicitly with `.equals` to actually determine if the values are the same. --- tests/units/test_event.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index a830566e80d..8292dd80464 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -9,6 +9,7 @@ BACKGROUND_TASK_MARKER, Event, EventChain, + EventChainVar, EventHandler, EventSpec, call_event_handler, @@ -32,7 +33,7 @@ def make_var(value) -> Var: return Var(_js_expr=value) -def make_timeout_logger(): +def make_timeout_logger() -> EventChainVar: return rx.vars.FunctionStringVar.create( "(...args) => { setTimeout(() => console.log('Timeout reached!', args), 1000); }" ).to(EventChain) @@ -683,7 +684,10 @@ def test_event_chain_create_allows_plain_function_var(): chain = EventChain.create(frontend_handler, args_spec=lambda: ()) assert isinstance(chain, EventChain) - assert chain.events == [frontend_handler] + assert len(chain.events) == 1 + chain_event = chain.events[0] + assert isinstance(chain_event, Var) + assert frontend_handler.equals(chain_event) def test_event_chain_create_wraps_plain_function_var_kwargs(): @@ -699,7 +703,10 @@ def test_event_chain_create_wraps_plain_function_var_kwargs(): ) assert isinstance(chain, EventChain) - assert chain.events == [frontend_handler] + assert len(chain.events) == 1 + chain_event = chain.events[0] + assert isinstance(chain_event, Var) + assert frontend_handler.equals(chain_event) assert chain.event_actions == {"preventDefault": True} @@ -714,7 +721,10 @@ def test_event_chain_create_wraps_event_chain_typed_function_var_kwargs(): ) assert isinstance(chain, EventChain) - assert chain.events == [frontend_handler] + assert len(chain.events) == 1 + chain_event = chain.events[0] + assert isinstance(chain_event, Var) + assert frontend_handler.equals(chain_event) assert chain.event_actions == {"preventDefault": True} @@ -739,7 +749,10 @@ def test_event_chain_create_allows_function_var_in_list(): chain = EventChain.create([frontend_handler], args_spec=lambda: ()) assert isinstance(chain, EventChain) - assert chain.events == [frontend_handler] + assert len(chain.events) == 1 + chain_event = chain.events[0] + assert isinstance(chain_event, Var) + assert frontend_handler.equals(chain_event) def test_button_accepts_mixed_event_handler_and_function_var(): From 4649bf1a6a33266e6fed67587e2ec1382ed785b2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 20 Mar 2026 11:44:19 -0700 Subject: [PATCH 11/15] Handle args_spec partial args for FunctionVar When a FunctionVar is passed to an event trigger with a given args_spec, transform it into a partial function that applies the transformed arguments when called. This allows FunctionVar handlers to work with on_blur and on_submit, which, by default, use the args_spec to transform the value before passing it off to the handler. Some escape hatches: * The behavior for EventChainVar is unchanged, so previous code that was explicitly casting functions to EventChain will continue to work without modification. * If the FunctionVar is returned through a lambda, no partial application is applied, because that happens at the point the lambda is called, so the return value of the lambda is responsible for mapping the arguments if desired. This change also allows event handler lambda functions to return a heterogeneous mix of EventSpec/EventHandler/FunctionVar (and EventChain returned from lambda are treated as FunctionVar, allowing arbitrary nesting). Update FunctionVar.partial such that passing no args does NOT create a new useless function. --- reflex/event.py | 38 +++++++++++++++++++------------------- reflex/vars/function.py | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index db3e37dd67d..1c75edb410b 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -520,21 +520,14 @@ def create( if isinstance(v, (EventHandler, EventSpec)): # Call the event handler to get the event. events.append(call_event_handler(v, args_spec, key=key)) - elif isinstance(v, (EventVar, FunctionVar)): + elif isinstance(v, (EventVar, EventChainVar)): events.append(v) + elif isinstance(v, FunctionVar): + # Apply the args_spec transformations as partial arguments to the function. + events.append(v.partial(*parse_args_spec(args_spec)[0])) elif isinstance(v, Callable): # Call the lambda to get the event chain. - result = call_event_fn(v, args_spec, key=key) - if isinstance(result, Var): - if isinstance(result, (EventVar, FunctionVar)): - events.append(result) - continue - msg = ( - f"Invalid event chain: {v}. Cannot use a Var-returning " - "lambda inside an EventChain list." - ) - raise ValueError(msg) - events.extend(result) + events.extend(call_event_fn(v, args_spec, key=key)) else: msg = f"Invalid event: {v}" raise ValueError(msg) @@ -1783,7 +1776,7 @@ def call_event_fn( fn: Callable, arg_spec: ArgsSpec | Sequence[ArgsSpec], key: str | None = None, -) -> list[EventSpec] | Var: +) -> list[EventSpec | FunctionVar]: """Call a function to a list of event specs. The function should return a single EventSpec, a list of EventSpecs, or a @@ -1816,10 +1809,6 @@ def call_event_fn( # Call the function with the parsed args. out = fn(*[*parsed_args][:number_of_fn_args]) - # If the function returns a Var, assume it's an EventChain and render it directly. - if isinstance(out, Var): - return out - # Convert the output to a list. if not isinstance(out, list): out = [out] @@ -1831,9 +1820,20 @@ def call_event_fn( # An un-called EventHandler gets all of the args of the event trigger. e = call_event_handler(e, arg_spec, key=key) + if isinstance(e, EventChain): + # Nested EventChain is treated like a FunctionVar. + e = Var.create(e) + # Make sure the event spec is valid. - if not isinstance(e, EventSpec): - msg = f"Lambda {fn} returned an invalid event spec: {e}." + if not isinstance(e, (EventSpec, FunctionVar)): + hint = "" + if isinstance(e, VarOperationCall): + hint = " Hint: use `fn.partial(...)` instead of calling the FunctionVar directly." + msg = ( + f"Invalid event chain for {key}: {fn} -> {e}: A lambda inside an EventChain " + "list must return `EventSpec | EventHandler | FunctionVar` or heterogeneous list of these types. " + f"Got: {type(e)}.{hint}" + ) raise EventHandlerValueError(msg) # Add the event spec to the chain. diff --git a/reflex/vars/function.py b/reflex/vars/function.py index d27c9852a95..709b9b03b05 100644 --- a/reflex/vars/function.py +++ b/reflex/vars/function.py @@ -186,7 +186,7 @@ def partial(self, *args: Var | Any) -> FunctionVar: # pyright: ignore [reportIn The partially applied function. """ if not args: - return ArgsFunctionOperation.create((), self) + return self return ArgsFunctionOperation.create( ("...args",), VarOperationCall.create(self, *args, Var(_js_expr="...args")), From 42282f794e8fac97e9c3f012567041df524ad12c Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 20 Mar 2026 12:05:12 -0700 Subject: [PATCH 12/15] treat outer lambda and lambda in list equivalently --- reflex/event.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 1c75edb410b..b410817bab9 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -513,9 +513,10 @@ def create( if isinstance(value, (EventHandler, EventSpec)): value = [value] + events: list[EventSpec | EventVar | FunctionVar] = [] + # If the input is a list of event handlers, create an event chain. if isinstance(value, list): - events: list[EventSpec | EventVar | FunctionVar] = [] for v in value: if isinstance(v, (EventHandler, EventSpec)): # Call the event handler to get the event. @@ -534,13 +535,7 @@ def create( # If the input is a callable, create an event chain. elif isinstance(value, Callable): - result = call_event_fn(value, args_spec, key=key) - if isinstance(result, Var): - # Recursively call this function if the lambda returned an EventChain Var. - return cls.create( - value=result, args_spec=args_spec, key=key, **event_chain_kwargs - ) - events = [*result] + events.extend(call_event_fn(value, args_spec, key=key)) # Otherwise, raise an error. else: From c516ce8d3872877f916b8889133034a09132f027 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 21 Mar 2026 00:10:22 +0500 Subject: [PATCH 13/15] test: add count --- tests/units/test_event.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 8292dd80464..650def9ef40 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -792,8 +792,8 @@ def do_a_thing(self, value: str): assert "applyEventActions(" not in rendered assert rendered.count("addEvents(") == 2 - assert '["debounce"] : 1000' in rendered - assert '["debounce"] : 200' in rendered + assert rendered.count('["debounce"] : 1000') == 1 + assert rendered.count('["debounce"] : 200') == 1 assert rendered.index("first x 1000") < rendered.index("second x 200") @@ -817,9 +817,9 @@ def do_a_thing(self, value: str): assert "applyEventActions(" in rendered assert rendered.count("addEvents(") == 2 - assert '["debounce"] : 1000' in rendered - assert '["debounce"] : 200' in rendered - assert '["preventDefault"] : true' in rendered + assert rendered.count('["debounce"] : 1000') == 1 + assert rendered.count('["debounce"] : 200') == 1 + assert rendered.count('["preventDefault"] : true') == 1 def test_event_chain_codegen_keeps_apply_event_actions_for_function_vars(): From cfa5a52c2426ccd1dfd23f6cfa2acfd0fd9efb30 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 20 Mar 2026 12:21:27 -0700 Subject: [PATCH 14/15] add EventVar to allowed event lambda return types this allows rx.cond to continue to be used for conditional events --- reflex/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/event.py b/reflex/event.py index b410817bab9..78b14e3640d 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -1820,7 +1820,7 @@ def call_event_fn( e = Var.create(e) # Make sure the event spec is valid. - if not isinstance(e, (EventSpec, FunctionVar)): + if not isinstance(e, (EventSpec, FunctionVar, EventVar)): hint = "" if isinstance(e, VarOperationCall): hint = " Hint: use `fn.partial(...)` instead of calling the FunctionVar directly." From 7a48fe36ae352d33a62110e650ee00fbdf3fab59 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 21 Mar 2026 00:54:04 +0500 Subject: [PATCH 15/15] fix: hoist empty event literals and drop redundant _get_all_var_data in statement block Cache empty event/action literals as module-level constants to avoid repeated allocations. Remove the manual _get_all_var_data merge on the statement block Var so nested var data propagates naturally through the f-string interpolation. --- reflex/event.py | 16 +++++++++++----- tests/units/test_event.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/reflex/event.py b/reflex/event.py index 78b14e3640d..62be49e7f51 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -88,6 +88,8 @@ def substate_token(self) -> str: _EVENT_FIELDS: set[str] = {f.name for f in dataclasses.fields(Event)} +_EMPTY_EVENTS = LiteralVar.create([]) +_EMPTY_EVENT_ACTIONS = LiteralVar.create({}) BACKGROUND_TASK_MARKER = "_reflex_background_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" @@ -2118,22 +2120,26 @@ def create( else invocation.call( LiteralVar.create([LiteralVar.create(event)]), arg_def_expr, - {}, + _EMPTY_EVENT_ACTIONS, ) ) for event in value.events ] if not statements: - statements.append(invocation.call(LiteralVar.create([]), arg_def_expr, {})) + statements.append( + invocation.call( + _EMPTY_EVENTS, + arg_def_expr, + _EMPTY_EVENT_ACTIONS, + ) + ) - statement_var_data = [statement._get_all_var_data() for statement in statements] if len(statements) == 1 and not value.event_actions: return_expr = statements[0] else: statement_block = Var( - _js_expr=f"{{{''.join(f'{statement!s};' for statement in statements)}}}", - _var_data=VarData.merge(*statement_var_data), + _js_expr=f"{{{''.join(f'{statement};' for statement in statements)}}}", ) if value.event_actions: apply_event_actions = FunctionStringVar.create( diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 650def9ef40..dec540c9059 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -480,6 +480,32 @@ def _args_spec(value: Var[int]) -> tuple[Var[int]]: ) +def test_event_chain_statement_block_preserves_nested_var_data(): + class S(BaseState): + x: Field[int] = field(0) + + @event + def s(self, value: int): + pass + + chain_var_data = Var.create( + EventChain( + events=[S.s(S.x), make_timeout_logger()], + args_spec=lambda: (), + ) + )._get_all_var_data() + + assert chain_var_data is not None + + x_var_data = S.x._get_all_var_data() + assert x_var_data is not None + + 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 + + def test_event_bound_method() -> None: class S(BaseState): @event