Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
08f2a11
feat(event): accept any sequence of events where a list of them is ac…
benedikt-bartscher Oct 3, 2026
f1844dc
Merge branch 'main' into event-sequences
benedikt-bartscher Oct 3, 2026
48d9f39
fix(app): read a page's on_load and the form on_submit events as any …
benedikt-bartscher Oct 3, 2026
1f783eb
perf(event): check the single event kinds before the Sequence ABC in …
benedikt-bartscher Oct 3, 2026
ca5293e
fix(internal): read the forms' on_submit sequence without the new hel…
benedikt-bartscher Oct 3, 2026
d22f188
fix(internal): reject a string as the forms' on_submit events instead…
benedikt-bartscher Oct 3, 2026
5e6680e
Merge remote-tracking branch 'upstream/main' into event-sequences
benedikt-bartscher Oct 8, 2026
ba4ac1e
fix(event): split any sequence an event lambda returns into its events
benedikt-bartscher Oct 8, 2026
307bbc1
refactor(event): share one fast check for a single event or a sequence
FarhanAliRaza Oct 13, 2026
2dcab7c
Merge branch 'main' into event-sequences
benedikt-bartscher Oct 13, 2026
8dfc13e
Merge branch 'main' into event-sequences
FarhanAliRaza Oct 13, 2026
bd5a272
build(reflex): require the reflex-base that has is_non_string_sequence
FarhanAliRaza Oct 13, 2026
624a078
Revert "build(reflex): require the reflex-base that has is_non_string…
FarhanAliRaza Oct 13, 2026
032af12
Merge branch 'main' into event-sequences
benedikt-bartscher Oct 14, 2026
312838c
Merge main into event sequences
masenf Oct 14, 2026
2d39820
feat(event): make event_sequence public and use it in the internal forms
FarhanAliRaza Oct 15, 2026
4c3e6d1
Merge remote-tracking branch 'upstream/main' into pr-7402
FarhanAliRaza Oct 15, 2026
dc13e2e
build(internal): require the reflex that has event_sequence
FarhanAliRaza Oct 15, 2026
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
1 change: 1 addition & 0 deletions news/+event-sequences.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A page's `on_load` accepts any sequence of events, e.g. a tuple, like a list of them.
1 change: 1 addition & 0 deletions packages/reflex-base/news/+event-sequences.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Event props and the events an event handler returns accept any sequence of events, e.g. a tuple or a variable typed `list[EventSpec]`, not only a list literal.
51 changes: 32 additions & 19 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
ArgsSpec,
GenericType,
Unset,
is_non_string_sequence,
safe_issubclass,
typehint_issubclass,
)
Expand Down Expand Up @@ -106,7 +107,7 @@ def state_cls(self) -> "type[BaseState]":
@classmethod
def from_event_type(
cls,
events: "IndividualEventType | list[IndividualEventType] | None",
events: "IndividualEventType | Sequence[IndividualEventType] | None",
*,
router_data: dict[str, Any] | None = None,
) -> "list[Event]":
Expand All @@ -123,13 +124,9 @@ def from_event_type(
if events is None:
return []

# If the handler returns a single event, wrap it in a list.
if not isinstance(events, list):
events = [events]

# Fix the events created by the handler.
out = []
for e in events:
for e in _event_sequence(events):
if callable(e) and getattr(e, "__name__", "") == "<lambda>":
# A lambda was returned, assume the user wants to call it with no args.
e = e()
Expand Down Expand Up @@ -1023,7 +1020,7 @@ def create(
events: list[EventSpec | EventVar | FunctionVar] = []

# If the input is a list of event handlers, create an event chain.
if isinstance(value, list):
if is_non_string_sequence(value):
Comment thread
greptile-apps[bot] marked this conversation as resolved.
for v in value:
if isinstance(v, (EventHandler, EventSpec)):
# Call the event handler to get the event.
Expand Down Expand Up @@ -2377,6 +2374,27 @@ def check_fn_match_arg_spec(
raise EventFnArgMismatchError(msg)


def _event_sequence(events: Any) -> Sequence[Any]:
"""Get events as a sequence, wrapping a single event-like value in a list.

The single event kinds come first: they are the common case, and cheaper to
check than the Sequence ABC.

Args:
events: A single event-like value or a sequence of them.

Returns:
The events as a sequence.
"""
if (
isinstance(events, (Event, EventSpec))
or callable(events)
or not is_non_string_sequence(events)
):
Comment thread
FarhanAliRaza marked this conversation as resolved.
return [events]
return events


def call_event_fn(
fn: Callable,
arg_spec: ArgsSpec | Sequence[ArgsSpec],
Expand Down Expand Up @@ -2414,10 +2432,6 @@ def call_event_fn(
# Call the function with the parsed args.
out = fn(*[*parsed_args][:number_of_fn_args])

# Normalize common heterogeneous event collections into individual events
# while keeping other scalar values for validation below.
out = list(out) if isinstance(out, (list, tuple)) else [out]

def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:
"""Wrap a mixed event-like Var into a callable frontend dispatcher.

Expand Down Expand Up @@ -2473,7 +2487,7 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:

# Convert any event specs to event specs.
events = []
for e in out:
for e in _event_sequence(out):
if isinstance(e, EventHandler):
# An un-called EventHandler gets all of the args of the event trigger.
e = call_event_handler(e, arg_spec, key=key)
Expand Down Expand Up @@ -2528,7 +2542,7 @@ def get_handler_args(


def fix_events(
events: list[EventSpec | EventHandler] | None,
events: Sequence[EventSpec | EventHandler] | None,
token: str | None = None,
router_data: dict[str, Any] | None = None,
) -> list[Event]:
Expand Down Expand Up @@ -2559,13 +2573,9 @@ def fix_events(
if events is None:
return []

# If the handler returns a single event, wrap it in a list.
if not isinstance(events, list):
events = [events]

# Fix the events created by the handler.
out = []
for e in events:
for e in _event_sequence(events):
if callable(e) and getattr(e, "__name__", "") == "<lambda>":
# A lambda was returned, assume the user wants to call it with no args.
e = e()
Expand Down Expand Up @@ -3052,7 +3062,10 @@ def __call__(self, *args: Var) -> Any:
type_params=(ARGS,),
)

ItemOrList = V | list[V]

# A Sequence rather than a list: lists are invariant, so a `list[EventSpec]`
# variable would not count as a list of every kind of event.
ItemOrList = V | Sequence[V]

BASIC_EVENT_TYPES = TypeAliasType(
"BASIC_EVENT_TYPES", EventSpec | EventHandler | Var[Any], type_params=()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from reflex_base.registry import RegisteredEventHandler
from reflex_base.utils.format import format_event_handler
from reflex_base.utils.serializers import deserializers
from reflex_base.utils.types import is_non_string_sequence

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -84,7 +85,7 @@ def _is_valid_type(events: Any) -> bool:
if events is None or _is_valid_type(events):
return events

if not (isinstance(events, Sequence) and not isinstance(events, (str, bytes))):
if not is_non_string_sequence(events):
events = [events]

try:
Expand Down
3 changes: 2 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from reflex_base import constants
from reflex_base.utils import exceptions
from reflex_base.utils.types import is_non_string_sequence

if TYPE_CHECKING:
from reflex_base.components.component import ComponentStyle
Expand Down Expand Up @@ -570,7 +571,7 @@ def _default_args_spec():
arg_def = "()"

payloads = []
if not isinstance(events, list):
if not is_non_string_sequence(events):
events = [events]

# Process each event/spec/lambda (similar to Component._create_event_chain).
Expand Down
21 changes: 20 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from typing import get_type_hints as get_type_hints_og

import typing_extensions
from typing_extensions import TypeAliasType, TypeVarTuple
from typing_extensions import TypeAliasType, TypeIs, TypeVarTuple
from typing_extensions import override as override

from reflex_base import constants
Expand Down Expand Up @@ -303,6 +303,25 @@ def is_none(cls: GenericType) -> bool:
return cls is type(None) or cls is None


def is_non_string_sequence(value: object) -> TypeIs[Sequence[Any]]:
"""Check if a value is a sequence other than a str or bytes, e.g. a list of events.

Separate list and tuple checks come first: they are the common case, and
cheaper than the Sequence ABC check.

Args:
value: The value to check.

Returns:
Whether the value is a sequence and not a str or bytes.
"""
return (
isinstance(value, list) # noqa: SIM101 - a lone list check is the fastest
or isinstance(value, tuple)
or (isinstance(value, Sequence) and not isinstance(value, (str, bytes)))
)


def is_union(cls: GenericType) -> bool:
"""Check if a class is a Union.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The demo and intro forms take any sequence of `on_submit` events, e.g. a tuple, like a list of them.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
based on company size.
"""

from collections.abc import Sequence
from typing import Any

import reflex as rx
Expand Down Expand Up @@ -295,7 +296,11 @@ def demo_form(
prefix = id_prefix or get_unique_variable_name()
email_id = f"{prefix}_user_email"
extra_on_submit = (
on_submit if isinstance(on_submit, list) else [on_submit] if on_submit else []
list(on_submit)
if isinstance(on_submit, Sequence)
else [on_submit]
if on_submit
else []
Comment thread
FarhanAliRaza marked this conversation as resolved.
Outdated
)
form = rx.el.form(
rx.el.div(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This module provides a comprehensive intro form that validates company emails.
"""

from collections.abc import Sequence
from typing import Any

import reflex as rx
Expand Down Expand Up @@ -293,7 +294,11 @@ def intro_form(
email_id = f"{prefix}_user_email"

extra: list[IndividualEventType[dict[str, Any]]] = (
on_submit if isinstance(on_submit, list) else [on_submit] if on_submit else []
list(on_submit)
if isinstance(on_submit, Sequence)
Comment thread
benedikt-bartscher marked this conversation as resolved.
Outdated
else [on_submit]
if on_submit
else []
)

form = rx.el.form(
Expand Down
15 changes: 13 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@
from reflex_base.telemetry_context import CompileTrigger, TelemetryContext
from reflex_base.utils import memo_paths
from reflex_base.utils.imports import ImportVar
from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send
from reflex_base.utils.types import (
ASGIApp,
Message,
Receive,
Scope,
Send,
is_non_string_sequence,
Comment thread
FarhanAliRaza marked this conversation as resolved.
)
from reflex_base.vars.dep_tracking import is_dependency
from reflex_components_core.base.error_boundary import ErrorBoundary
from reflex_components_core.base.fragment import Fragment
Expand Down Expand Up @@ -1159,7 +1166,11 @@ def _commit_page(
state.setup_dynamic_args(prepared.route_args)

self._load_events[page.route] = (
(page.on_load if isinstance(page.on_load, list) else [page.on_load])
(
list(page.on_load)
if is_non_string_sequence(page.on_load)
else [page.on_load]
)
if page.on_load is not None
else []
)
Expand Down
16 changes: 14 additions & 2 deletions tests/type_checking/event.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Calling an event handler on the state class types as the event it creates."""
"""Inferred types for `reflex_base.event` that are part of the public contract."""

from collections.abc import Callable
from typing import Any, assert_type

from reflex_base.event import EventCallback, event
from reflex_base.event import EventCallback, EventSpec, EventType, event
from reflex_base.vars.base import Var

from reflex.state import State
Expand Down Expand Up @@ -46,3 +46,15 @@ def four(self, index: int, value: str, flag: bool, count: float) -> None: ...
# A handler is a callable of its arguments, so it can be passed where one is expected.
_one: Callable[[int], Any] = _HandlerState.one
_two: Callable[[int, str], Any] = _HandlerState.two


def _event_prop(value: EventType[()]) -> None: ...


def _event_sequences(
specs: list[EventSpec], handlers: tuple[EventCallback[()], ...]
) -> None:
Comment thread
FarhanAliRaza marked this conversation as resolved.
# An event prop takes a list variable of events, not only a list literal,
# and a tuple of them.
_event_prop(specs)
_event_prop(handlers)
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,48 @@ def client_event(spec, router_data: dict[str, Any]) -> Event:
assert (await state.get_state(RouterState)).seen == ["/item/abc|abc"]


async def test_handler_chains_a_tuple_of_events(
wired_app: App,
real_base_state_processor: BaseStateEventProcessor,
token: str,
):
"""A handler can return or yield its chained events as a tuple.

Args:
wired_app: The App wired to the processor's state manager.
real_base_state_processor: The unmocked BaseStateEventProcessor.
token: The client token.
"""

class TupleState(State):
log: list[str] = []

@event
def add(self, tag: str):
self.log = [*self.log, tag]

@event
def returns(self):
return TupleState.add("r1"), TupleState.add("r2")

@event
def yields(self):
yield TupleState.add("y1"), TupleState.add("y2")

async with real_base_state_processor as processor:
await processor.enqueue(token, Event.from_event_type(TupleState.returns())[0])
await processor.join(5)
await processor.enqueue(token, Event.from_event_type(TupleState.yields())[0])
await processor.join(5)

root_ctx = real_base_state_processor._root_context
assert root_ctx is not None
state = await root_ctx.state_manager.get_state(
BaseStateToken(ident=token, cls=State)
)
assert (await state.get_state(TupleState)).log == ["r1", "r2", "y1", "y2"]


async def test_ensure_locked_returns_a_root_only_while_the_lock_is_held(
wired_app: App,
real_base_state_processor: BaseStateEventProcessor,
Expand Down
8 changes: 8 additions & 0 deletions tests/units/reflex_base/utils/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ def test_format_queue_events_empty():
assert str(format.format_queue_events(None)) == "(() => null)"


def test_format_queue_events_accepts_a_tuple():
"""A tuple of events formats like a list of them."""
events = (rx.console_log("a"), rx.console_log("b"))
assert str(format.format_queue_events(events)) == str(
format.format_queue_events(list(events))
)


def test_format_queue_events_args_spec():
"""The args spec names the callback parameters."""
var = format.format_queue_events(
Expand Down
Loading
Loading