Skip to content

Support FunctionVar handlers in EventChain rendering#6188

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

Support FunctionVar handlers in EventChain rendering#6188
FarhanAliRaza wants to merge 16 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 (7a48fe3) with main (7ee3026)

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..."

… 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.

…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
)
].strip()

def _wrap_event_handler_calls(handler_js: str) -> str:
Copy link
Collaborator

Choose a reason for hiding this comment

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

seems like it might be better to just make these replacements in the asserted string

Copy link
Collaborator

Choose a reason for hiding this comment

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

seems this function can be removed?

@masenf
Copy link
Collaborator

masenf commented Mar 19, 2026

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

made the fix in #6196

you can merge from main into your branch to bring it in

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.
Comment on lines +782 to +783
assert '["debounce"] : 1000' in rendered
assert '["debounce"] : 200' in rendered
Copy link
Collaborator

Choose a reason for hiding this comment

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

probably also assert that these each appear once in the rendered output

Comment on lines 807 to 809
assert '["debounce"] : 1000' in rendered
assert '["debounce"] : 200' in rendered
assert '["preventDefault"] : true' in rendered
Copy link
Collaborator

Choose a reason for hiding this comment

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

similarly here, lets assert that each of these action strings appear exactly once in the code.

masenf added 3 commits March 20, 2026 11:42
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.
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.
@masenf
Copy link
Collaborator

masenf commented Mar 20, 2026

@FarhanAliRaza I added some commits to catch some additional cases that were missing while i was testing manually.

Specifically code like this:

        rx.form(
            rx.input(
                name="input_value",
                on_blur=lambda v: rx.EventChain.create(
                    value=log_after_timeout.partial(v.to(str).capitalize()),
                    args_spec=lambda: (),
                ),
            ),
            rx.button(
                "Submit",
            ),
            on_submit=[
                log_after_timeout,
                lambda fd: [
                    log_after_timeout.partial(fd.to(dict)["input_value"]),
                    rx.console_log(fd.to(dict)["input_value"]),
                ],
                State.on_submit,
            ],
        ),

2 behavior changes:

  • When a FunctionVar is passed, the arguments dictated by the args_spec for the event trigger must be applied as a .partial so the handler gets the transformed args
  • When a lambda is passed in the chain, it should be able to return the same types of heterogeneous values that are accepted by the chain itself.

Additionally, lambda return value validation was consolidated into call_event_fn.

Can you explore more edge cases around this and add some additional test cases to the unit tests suite.

FarhanAliRaza and others added 2 commits March 21, 2026 00:10
this allows rx.cond to continue to be used for conditional events
@masenf
Copy link
Collaborator

masenf commented Mar 20, 2026

test failure was from another interesting case that we don't have a unit test for yet

            on_key_down=lambda key: rx.cond(
                rx.Var.create(["a", "s", "d", "w"]).contains(key),
                GlobalHotkeyState.update_key(key),
                rx.console_log(key)
            )

here the event handler is a lambda that returns some conditional code that runs on the frontend before deciding whether to trigger a backend event or do something else.

Stuff like this still doesn't work though

            rx.input(
                name="input_value",
                on_blur=lambda v: rx.cond(
                    v == "foo",
                    log_after_timeout.partial("Input was foo!"),
                    State.do_a_thing(v),
                ),
            ),

When we have an rx.cond that returns heterogenous values (either FunctionVar or EventVar), then that gets rejected by the new lambda return type checking logic. Idk if we want to allow that to return any arbitrary var, or if requiring the user to say rx.cond(...).to(rx.event.EventVar) is good enough for now. I'm probably more inclined to the latter.

@FarhanAliRaza
Copy link
Collaborator Author

FarhanAliRaza commented Mar 20, 2026

I fixed it locally by adding this.

Is it correct?

 elif isinstance(e, Var) and safe_issubclass(
            e._var_type, (EventSpec, EventChain)
        ):
            # Helpers like rx.cond() can return event-typed Vars that should be
            # treated the same as literal EventSpec/EventChain values here.
            e = e.guess_type()
            

Update:
But now that integeration test passes so not pushing this.

@masenf
Copy link
Collaborator

masenf commented Mar 20, 2026

When we have an rx.cond that returns heterogenous values (either FunctionVar or EventVar), then that gets rejected by the new lambda return type checking logic. Idk if we want to allow that to return any arbitrary var, or if requiring the user to say rx.cond(...).to(rx.event.EventVar) is good enough for now. I'm probably more inclined to the latter.

I think we have some special logic somewhere that is treating the rx.cond case specially. Even with the explicit cast to EventVar, i'm not able to get it to work because it wants to treat the whole thing as an EventVar (and thus wrap it in addEvents), which just doesn't work if one of the branches of the rx.cond is not a real EventSpec.

I think we can just defer on this problem for now; it wasn't working before and still doesn't work now. We could improve it in the future.

reflex/event.py Outdated
if not statements:
statements.append(invocation.call(LiteralVar.create([]), arg_def_expr, {}))

statement_var_data = [statement._get_all_var_data() for statement in statements]
Copy link
Collaborator

Choose a reason for hiding this comment

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

i think this might be the line that is causing the CodSpeed regression.

usually if you just combine the vars, the parser will pick up any nested VarData automatically:

            statement_block = Var(
                _js_expr=f"{{{''.join(f'{statement};' for statement in statements)}}}",
            )

i would expect the above to be equivalent to how it was written without the explicit VarData merge

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You were right, it is fixed now.

…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.
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