Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 58 additions & 38 deletions reflex/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<string, (number|boolean)>} event_actions The actions to apply.
* @param {Array<any>|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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1934,11 +1934,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

Expand All @@ -1963,6 +1958,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:
Expand Down
7 changes: 6 additions & 1 deletion reflex/constants/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
],
}


Expand Down
124 changes: 89 additions & 35 deletions reflex/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,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"
Expand Down Expand Up @@ -449,8 +452,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)
Expand Down Expand Up @@ -481,9 +484,18 @@ def create(
"""
# If it's an event chain var, return it.
if isinstance(value, Var):
if isinstance(value, EventChainVar):
# 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(
f"event_chain_kwargs {event_chain_kwargs!r} are ignored for "
"EventChainVar values.",
stacklevel=2,
)
return value
if isinstance(value, EventVar):
if isinstance(value, (EventVar, FunctionVar)):
value = [value]
elif safe_issubclass(value._var_type, (EventChain, EventSpec)):
return cls.create(
Expand All @@ -503,38 +515,29 @@ 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] = []
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, 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):
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)
events.extend(call_event_fn(v, args_spec, key=key))
else:
msg = f"Invalid event: {v}"
raise ValueError(msg)

# 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:
Expand Down Expand Up @@ -1770,7 +1773,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
Expand Down Expand Up @@ -1803,10 +1806,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]
Expand All @@ -1818,9 +1817,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, EventVar)):
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.
Expand Down Expand Up @@ -2075,9 +2085,11 @@ 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_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))
else:
# add a default argument for addEvents if none were specified in value.args_spec
# used to trigger the preventDefault() on the event.
Expand All @@ -2098,17 +2110,59 @@ 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

call_args = arg_vars if sig.parameters else (Var(_js_expr="...args"),)
statements = [
(
event.call(*call_args)
if isinstance(event, FunctionVar)
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(
_EMPTY_EVENTS,
arg_def_expr,
_EMPTY_EVENT_ACTIONS,
)
)

if len(statements) == 1 and not value.event_actions:
return_expr = statements[0]
else:
statement_block = Var(
_js_expr=f"{{{''.join(f'{statement};' for statement in statements)}}}",
)
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="",
_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,
)

Expand Down
Loading
Loading