Skip to content

Support FunctionVar handlers in EventChain rendering#6188

Open
FarhanAliRaza wants to merge 6 commits intoreflex-dev:mainfrom
FarhanAliRaza:mixed-events
Open

Support FunctionVar handlers in EventChain rendering#6188
FarhanAliRaza wants to merge 6 commits intoreflex-dev:mainfrom
FarhanAliRaza:mixed-events

Conversation

@FarhanAliRaza
Copy link
Collaborator

@FarhanAliRaza FarhanAliRaza commented Mar 17, 2026

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.

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

closes #6185

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.
@FarhanAliRaza FarhanAliRaza marked this pull request as draft March 17, 2026 22:38
@codspeed-hq
Copy link

codspeed-hq bot commented Mar 17, 2026

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing FarhanAliRaza:mixed-events (c2380bc) with main (d45a1bb)

Open in CodSpeed

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR adds support for FunctionVar handlers inside EventChain, enabling mixed chains of backend EventSpec/EventVar items and frontend FunctionVar items to be rendered as valid JavaScript while preserving execution order and DOM event actions.

Key changes:

  • EventChain.create now short-circuits on bare FunctionVar inputs (with a UserWarning when event_chain_kwargs are silently ignored).
  • LiteralEventChainVar.create detects mixed chains and emits an arrow-function body that interleaves addEvents(...) calls for backend groups with direct frontend function invocations for FunctionVar entries. DOM event actions (preventDefault / stopPropagation) are extracted and re-emitted as manual DOM calls; non-DOM actions (e.g. throttle) are forwarded to every addEvents group.
  • _starts_with_arrow_function in function.py replaces the prior coarse "=>" in expr check with a proper parser that distinguishes single-identifier arrows, parenthesised-param arrows, and async arrows from factory calls and string literals — fixing false positives that would have double-wrapped already-valid expressions.
  • call_args are now threaded through to inline FunctionVar invocations so trigger arguments (e.g. the DOM Event object) reach frontend handlers correctly.
  • The Raises docstring in app.py is moved to the inner helper that actually raises the exceptions.

Confidence Score: 3/5

  • Safe to merge after fixing the async( arrow-function detection gap in _starts_with_arrow_function.
  • The core logic in event.py and the tests in test_format.py are solid and well-verified. The one genuine logic issue is in _starts_with_arrow_function: async(x) => x (no space between async and () is a valid JS async arrow function but the function returns False for it, causing the call expression to be emitted without parenthesisation and producing incorrect JavaScript. The test-naming issue in test_event_event.py leaves the no-kwargs FunctionVar early-return path without dedicated coverage, but the logic itself is trivially correct.
  • reflex/vars/function.py — the async( (no-space) edge case in _starts_with_arrow_function.

Important Files Changed

Filename Overview
reflex/vars/function.py Adds _starts_with_arrow_function parser and updates VarOperationCall._cached_var_name to wrap arrow functions in parens before calling them. The parser handles single-identifier, parenthesised-param, and async (space) arrow forms correctly, but the async( (no-space) async-arrow form falls through to the identifier path and returns False, causing an incorrect call expression for that edge case.
reflex/event.py Core change: EventChain.create now accepts FunctionVar values (with warnings when event_chain_kwargs are present), and LiteralEventChainVar.create splits mixed chains into interleaved addEvents(...) calls and direct frontend function invocations. DOM event actions are extracted and re-emitted as manual preventDefault/stopPropagation calls; non-DOM event actions are forwarded to each addEvents group. Logic appears correct; the invocation null-guard via assert is cleaner than the prior state.
tests/units/test_event.py Adds unit tests for FunctionVar event chain creation and warning behaviour. test_event_chain_create_allows_plain_function_var exercises the EventChainVar early-return path instead of the new FunctionVar path it claims to test, leaving the no-kwargs FunctionVar early-return uncovered.
tests/units/utils/test_format.py Comprehensive rendering tests for pure-EventSpec, pure-FunctionVar, and mixed event chains, including chains with DOM event actions and non-DOM (e.g. throttle) event actions. Assertions pin the exact JS output, providing strong regression protection.
tests/units/test_var.py Extends test_function_var with three new cases that verify the arrow-function wrapping heuristic: a top-level spread arrow function, a factory call containing a nested arrow function, and a factory call containing "=>" inside a string literal. All three correctly exercise false-positive paths.
reflex/app.py Docstring-only change: moves the Raises section from the outer upload function to the inner helper that actually raises the exceptions. No logic changes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["EventChain.create(value)"] --> B{isinstance value Var?}
    B -- Yes --> C{EventChainVar?}
    C -- Yes --> D["warn if kwargs\nreturn value as-is"]
    C -- No --> E{FunctionVar?}
    E -- Yes --> F["warn if kwargs\nreturn value as-is"]
    E -- No --> G{EventVar?}
    G -- Yes --> H["wrap in list → list path"]
    G -- No --> I["guess type / raise"]
    B -- No --> J{list?}
    J -- Yes --> K["for each item in list"]
    K --> L{EventHandler / EventSpec?}
    L -- Yes --> M["call_event_handler → EventSpec"]
    L -- No --> N{EventVar or FunctionVar?}
    N -- Yes --> O["append directly to events"]
    N -- No --> P{Callable / lambda?}
    P -- Yes --> Q["call_event_fn → result"]
    Q --> R{result is EventVar or FunctionVar?}
    R -- Yes --> O
    R -- No --> S["raise ValueError"]
    M --> T["events list built"]
    O --> T
    T --> U["return EventChain(events=…)"]

    U --> V["LiteralEventChainVar.create(EventChain)"]
    V --> W{any FunctionVar in events?}
    W -- No --> X["invocation.call(all events, arg_def, event_actions)"]
    W -- Yes --> Y["build JS block statement"]
    Y --> Z["emit preventDefault/stopPropagation if needed"]
    Z --> AA["for each event"]
    AA --> AB{FunctionVar?}
    AB -- Yes --> AC["flush backend group → addEvents(…);\nevent.call(call_args);"]
    AB -- No --> AD["append to queueable_group"]
    AC --> AA
    AD --> AA
    AA --> AE["flush remaining backend group"]
    AE --> AF["return_expr = Var(JS block)"]
    X --> AG["return LiteralEventChainVar"]
    AF --> AG
Loading

Last reviewed commit: "fix: forward non-DOM..."

Comment on lines +242 to +244
func_expr = str(self._func)
if "=>" in func_expr and not format.is_wrapped(func_expr, "("):
func_expr = format.wrap(func_expr, "(")
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 => detection is unreliable for complex expressions

The check "=>" in func_expr is a coarse string scan that fires whenever => appears anywhere in the serialised expression — including inside nested arrow functions in function bodies (e.g. (...args) => { const f = x => x + 1; return f(args); }) or inside template literals / string arguments.

Although wrapping an already-valid expression in extra parentheses is harmless for pure arrow-function strings (JavaScript treats (expr) the same as expr), a false positive on a non-arrow-function expression that happens to contain the => character sequence could generate unexpected JS output in edge cases.

A more robust approach would be to track whether a FunctionVar is an inline arrow function at construction time (e.g. a boolean flag _is_arrow_fn on the FunctionVar/FunctionStringVar), or at minimum restrict the heuristic to expressions that begin with an arrow-function parameter list:

import re
_ARROW_FN_RE = re.compile(r"^\s*(\(.*?\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>")

func_expr = str(self._func)
if _ARROW_FN_RE.match(func_expr) and not format.is_wrapped(func_expr, "("):
    func_expr = format.wrap(func_expr, "(")

reflex/event.py Outdated
Comment on lines +2141 to +2153
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()
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 invocation is unsafely used without a null guard inside closure

flush_queueable_group is a closure that calls invocation.call(...) on line 2144. At the point where the closure is defined, the type of invocation (from the perspective of both the type checker and a careful reader) is FunctionVar | None — the raise ValueError guard above only eliminates the case invocation is not None and not FunctionVar, leaving None as a valid residual type.

In practice invocation is always non-None here (it was just set a few lines earlier), but the closure captures the variable binding, not a guaranteed-non-None value. A type checker will flag invocation.call(...) as a potential None dereference, and the code is brittle if the assignment logic above ever changes.

Add an assert invocation is not None before the closure definition, or restructure to pass invocation as a parameter:

assert invocation is not None  # guaranteed non-None by the assignments above

def flush_queueable_group() -> None:
    if not queueable_group:
        return
    queue_call = invocation.call(   # type checker now knows it's FunctionVar
        ...
    )

reflex/event.py Outdated
Comment on lines +2118 to +2168
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),
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Non-preventDefault/stopPropagation event actions silently dropped in mixed mode

In the mixed-case branch (has_function_var = True), flush_queueable_group always passes {} as event_actions to addEvents. Only preventDefault and stopPropagation are explicitly re-implemented via manual DOM calls at the top of the block.

If value.event_actions ever contains keys beyond those two (e.g. a future action type, or user-supplied data), they are silently discarded in every addEvents(...) call within the mixed path. This is in contrast to the pure-EventSpec path (line 2116) which correctly passes value.event_actions through.

Consider forwarding any remaining event_actions to addEvents:

# Strip the DOM-managed keys; forward everything else to addEvents
remaining_actions = {
    k: v
    for k, v in value.event_actions.items()
    if k not in ("preventDefault", "stopPropagation")
}

# then in flush_queueable_group:
queue_call = invocation.call(
    LiteralVar.create([...]),
    arg_def_expr,
    remaining_actions,   # was hardcoded {}
)

Comment on lines 483 to +487
if isinstance(value, Var):
if isinstance(value, EventChainVar):
return value
if isinstance(value, FunctionVar):
return value
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 event_chain_kwargs silently ignored for bare FunctionVar inputs

When value is a FunctionVar, create returns it directly without applying event_chain_kwargs (which may include invocation, custom event_actions, etc.). This means callers that do:

EventChain.create(some_fn_var, args_spec=..., event_actions={"preventDefault": True})

will silently get back some_fn_var with none of those kwargs applied. The discrepancy is not documented and could confuse users who expect event_actions to be respected for FunctionVar inputs.

At minimum this behaviour should be documented with a comment explaining the intentional early-return, or a warning should be emitted if event_chain_kwargs is non-empty:

if isinstance(value, FunctionVar):
    # FunctionVar is its own event handler; event_chain_kwargs are intentionally ignored.
    if event_chain_kwargs:
        warnings.warn(
            f"event_chain_kwargs {event_chain_kwargs!r} are ignored for FunctionVar values.",
            stacklevel=2,
        )
    return value

… 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.
@FarhanAliRaza FarhanAliRaza marked this pull request as ready for review March 18, 2026 14:27
@FarhanAliRaza
Copy link
Collaborator Author

Does it require documentation?

@FarhanAliRaza FarhanAliRaza requested a review from masenf March 18, 2026 14:54
Copy link
Collaborator

@masenf masenf left a comment

Choose a reason for hiding this comment

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

The overarching theme of this review is to focus on unifying various code paths to avoid complexity and special cases that might not be covered well by tests. If there is only one path to accomplish X, then we're less likely to regress an edge case with future changes.

"""
return f"({self._func!s}({', '.join([str(LiteralVar.create(arg)) for arg in self._args])}))"
func_expr = str(self._func)
if _starts_with_arrow_function(func_expr) and not format.is_wrapped(
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we really need the complex logic to detect an arrow function or can we just unconditionally wrap the expression in parentheses if it's not already wrapped

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It breaks the integration test without this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It emitts invalid code like ((typeof)(value))

reflex/event.py Outdated
Comment on lines +2143 to +2159
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();}"
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

code for handling the event actions (with the addition of throttle and debounce) already exists in state.js, but it's inside the addEvents functions. i would suggest pulling this logic out as an exported function in state.js that takes the event arg and a function which could either be an addEvents partial or some user-passed function.

instead of collecting all the event actions and combining them into the EventChain, it probably is better structure to leave the event actions on the individual EventSpec, and apply any further event actions to the event chain itself. Then use the same function to process these actions at each level.

For example, you might have applyEventActions(target, event_actions, ...args); in this case the target could be the entire EventChain function if the chain has its own actions, or the actions could be passed to each addEvents call (which internally would call applyEventActions).

reflex/event.py Outdated
stacklevel=2,
)
return value
if isinstance(value, EventVar):
Copy link
Collaborator

Choose a reason for hiding this comment

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

include FunctionVar here and remove the special case for it above. We should now be treating EventVar and FunctionVar in the same way: creating a new event chain that includes these values in the events list so they can be composed and formatted as an EventChainVar later.

reflex/event.py Outdated

has_function_var = any(isinstance(e, FunctionVar) for e in value.events)

if not has_function_var:
Copy link
Collaborator

Choose a reason for hiding this comment

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

we should strive to reduce special cases in this function. i think it's absolutely fine if we process both mixed and homogenous cases with the same logic.

it's actually better if we have a separate addEvents call for each EventSpec because then each of those can have different event actions attached to them and the system is more composable.

reflex/event.py Outdated
statement_js.append(f"{function_call!s};")
statement_var_data.append(function_call._get_all_var_data())
else:
queueable_group.append(event)
Copy link
Collaborator

Choose a reason for hiding this comment

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

just to reiterate, i don't think we need or want to batch up these groups; each non-FunctionVar should get its own addEvents invocation.

…ndering

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.
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.
@FarhanAliRaza
Copy link
Collaborator Author

The failing test is flaky
It can be fixed like this but i dont know if it is right decision.

diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py
index 63fc9058..e59d57a8 100644
--- a/reflex/istate/manager/redis.py
+++ b/reflex/istate/manager/redis.py
@@ -153,6 +153,10 @@ class StateManagerRedis(StateManager):
         default_factory=dict,
         init=False,
     )
+    _lock_updates_subscribed: asyncio.Event = dataclasses.field(
+        default_factory=asyncio.Event,
+        init=False,
+    )
     _lock_task: asyncio.Task | None = dataclasses.field(default=None, init=False)
 
     # Whether debug prints are enabled.
@@ -797,8 +801,12 @@ class StateManagerRedis(StateManager):
         }
         async with self.redis.pubsub() as pubsub:
             await pubsub.psubscribe(**handlers)  # pyright: ignore[reportArgumentType]
-            async for _ in pubsub.listen():
-                pass
+            self._lock_updates_subscribed.set()
+            try:
+                async for _ in pubsub.listen():
+                    pass
+            finally:
+                self._lock_updates_subscribed.clear()
 
     def _ensure_lock_task(self) -> None:
         """Ensure the lock updates subscriber task is running."""
@@ -954,6 +962,9 @@ class StateManagerRedis(StateManager):
             lock_id: The ID of the lock.
         """
         token = lock_key.decode().rsplit("_lock", 1)[0]
+        if self._oplock_enabled:
+            self._ensure_lock_task()
+            await self._lock_updates_subscribed.wait()
         if (
             # If there's not a line, try to get the lock immediately.
             not self._n_lock_waiters(lock_key)
@@ -964,8 +975,6 @@ class StateManagerRedis(StateManager):
                     f"{SMR} [{time.monotonic() - start:.3f}] {lock_key.decode()} instaque by {lock_id.decode()}"
                 )
             return
-        # Make sure lock waiter task is running.
-        self._ensure_lock_task()
         async with (
             self._lock_waiter(lock_key) as lock_released_event,
             self._request_lock_release(lock_key, lock_id),

@FarhanAliRaza FarhanAliRaza requested a review from masenf March 19, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix EventChain to separately invoke addEvents for mixed event types

2 participants