Skip to content

Commit a084b58

Browse files
Support FunctionVar handlers in EventChain rendering (#6188)
* 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. * 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. * 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. * 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. * test: fix test regression * perf: fast-path backend-only EventChains to skip applyEventActions wrapper * test: simplify test * fix: allow EventChain-typed FunctionVars to compose with event_chain_kwargs * 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. * 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. * 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. * treat outer lambda and lambda in list equivalently * test: add count * add EventVar to allowed event lambda return types this allows rx.cond to continue to be used for conditional events * 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. * test: Addded more tests --------- Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent 06e733a commit a084b58

File tree

10 files changed

+724
-142
lines changed

10 files changed

+724
-142
lines changed

reflex/.templates/web/utils/state.js

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,53 @@ export const ReflexEvent = (
726726
return { name, payload, handler, event_actions };
727727
};
728728

729+
/**
730+
* Apply event actions before invoking a target function.
731+
* @param {Function} target The function to invoke after applying event actions.
732+
* @param {Object.<string, (number|boolean)>} event_actions The actions to apply.
733+
* @param {Array<any>|any} args The event args.
734+
* @param {string|null} action_key A stable key for debounce/throttle tracking.
735+
* @param {Function|null} temporal_handler Returns whether temporal actions may run.
736+
* @returns The target result, if it runs immediately.
737+
*/
738+
export const applyEventActions = (
739+
target,
740+
event_actions = {},
741+
args = [],
742+
action_key = null,
743+
temporal_handler = null,
744+
) => {
745+
if (!(args instanceof Array)) {
746+
args = [args];
747+
}
748+
749+
const _e = args.find((o) => o?.preventDefault !== undefined);
750+
751+
if (event_actions?.preventDefault && _e?.preventDefault) {
752+
_e.preventDefault();
753+
}
754+
if (event_actions?.stopPropagation && _e?.stopPropagation) {
755+
_e.stopPropagation();
756+
}
757+
if (event_actions?.temporal && temporal_handler && !temporal_handler()) {
758+
return;
759+
}
760+
761+
const invokeTarget = () => target(...args);
762+
const resolved_action_key = action_key ?? target.toString();
763+
764+
if (event_actions?.throttle) {
765+
if (!throttle(resolved_action_key, event_actions.throttle)) {
766+
return;
767+
}
768+
}
769+
if (event_actions?.debounce) {
770+
debounce(resolved_action_key, invokeTarget, event_actions.debounce);
771+
return;
772+
}
773+
return invokeTarget();
774+
};
775+
729776
/**
730777
* Package client-side storage values as payload to send to the
731778
* backend with the hydrate event
@@ -898,51 +945,24 @@ export const useEventLoop = (
898945
// Function to add new events to the event queue.
899946
const addEvents = useCallback((events, args, event_actions) => {
900947
const _events = events.filter((e) => e !== undefined && e !== null);
901-
if (!event_actions?.temporal) {
902-
// Reconnect socket if needed for non-temporal events.
903-
ensureSocketConnected();
904-
}
905-
906-
if (!(args instanceof Array)) {
907-
args = [args];
908-
}
909948

910949
event_actions = _events.reduce(
911950
(acc, e) => ({ ...acc, ...e.event_actions }),
912951
event_actions ?? {},
913952
);
914953

915-
const _e = args.filter((o) => o?.preventDefault !== undefined)[0];
916-
917-
if (event_actions?.preventDefault && _e?.preventDefault) {
918-
_e.preventDefault();
919-
}
920-
if (event_actions?.stopPropagation && _e?.stopPropagation) {
921-
_e.stopPropagation();
922-
}
923-
const combined_name = _events.map((e) => e.name).join("+++");
924-
if (event_actions?.temporal) {
925-
if (!socket.current || !socket.current.connected) {
926-
return; // don't queue when the backend is not connected
927-
}
928-
}
929-
if (event_actions?.throttle) {
930-
// If throttle returns false, the events are not added to the queue.
931-
if (!throttle(combined_name, event_actions.throttle)) {
932-
return;
933-
}
934-
}
935-
if (event_actions?.debounce) {
936-
// If debounce is used, queue the events after some delay
937-
debounce(
938-
combined_name,
939-
() =>
940-
queueEvents(_events, socket, false, navigate, () => params.current),
941-
event_actions.debounce,
942-
);
943-
} else {
944-
queueEvents(_events, socket, false, navigate, () => params.current);
954+
if (!event_actions?.temporal) {
955+
// Reconnect socket if needed for non-temporal events.
956+
ensureSocketConnected();
945957
}
958+
959+
return applyEventActions(
960+
() => queueEvents(_events, socket, false, navigate, () => params.current),
961+
event_actions,
962+
args,
963+
_events.map((e) => e.name).join("+++"),
964+
() => !!socket.current?.connected,
965+
);
946966
}, []);
947967

948968
const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode

reflex/app.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,11 +1936,6 @@ async def upload_file(request: Request):
19361936
Returns:
19371937
StreamingResponse yielding newline-delimited JSON of StateUpdate
19381938
emitted by the upload handler.
1939-
1940-
Raises:
1941-
UploadValueError: if there are no args with supported annotation.
1942-
UploadTypeError: if a background task is used as the handler.
1943-
HTTPException: when the request does not include token / handler headers.
19441939
"""
19451940
from reflex.utils.exceptions import UploadTypeError, UploadValueError
19461941

@@ -1965,6 +1960,11 @@ async def _create_upload_event() -> Event:
19651960
19661961
Returns:
19671962
The upload event backed by the original temp files.
1963+
1964+
Raises:
1965+
UploadValueError: If there are no uploaded files or supported annotations.
1966+
UploadTypeError: If a background task is used as the handler.
1967+
HTTPException: If the request is missing token or handler headers.
19681968
"""
19691969
files = form_data.getlist("files")
19701970
if not files:

reflex/constants/compiler.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class CompileVars(SimpleNamespace):
6161
IS_HYDRATED = "is_hydrated"
6262
# The name of the function to add events to the queue.
6363
ADD_EVENTS = "addEvents"
64+
# The name of the function to apply event actions before invoking a target.
65+
APPLY_EVENT_ACTIONS = "applyEventActions"
6466
# The name of the var storing any connection error.
6567
CONNECT_ERROR = "connectErrors"
6668
# The name of the function for converting a dict to an event.
@@ -128,7 +130,10 @@ class Imports(SimpleNamespace):
128130
EVENTS = {
129131
"react": [ImportVar(tag="useContext")],
130132
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
131-
f"$/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
133+
f"$/{Dirs.STATE_PATH}": [
134+
ImportVar(tag=CompileVars.TO_EVENT),
135+
ImportVar(tag=CompileVars.APPLY_EVENT_ACTIONS),
136+
],
132137
}
133138

134139

0 commit comments

Comments
 (0)