From b7bbcedbd630c201aec92ea03024fa09ab4175b9 Mon Sep 17 00:00:00 2001 From: Tim Haselhoff Date: Fri, 17 Jul 2026 14:56:29 +0200 Subject: [PATCH 01/10] Cancel previous background tasks --- .../src/reflex_base/event/__init__.py | 24 ++++++ .../event/processor/event_processor.py | 17 +++++ tests/units/test_state.py | 75 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 919e1f2fd1f..028482c9f62 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -291,6 +291,7 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: BACKGROUND_TASK_MARKER = "_reflex_background_task" +CANCEL_PREVIOUS_TASK_MARKER = "_reflex_cancel_previous_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" @@ -548,6 +549,19 @@ def is_background(self) -> bool: """ return getattr(self.fn, BACKGROUND_TASK_MARKER, False) + @property + def is_cancel_previous_task(self) -> bool: + """Whether starting this handler cancels its previous in-flight run. + + Only meaningful for background handlers. When True, dispatching a new + run cancels the still-running task from a prior run of the same handler + for the same client token. + + Returns: + True if the event handler is marked to cancel its previous run. + """ + return getattr(self.fn, CANCEL_PREVIOUS_TASK_MARKER, False) + def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": """Pass arguments to the handler to get an event spec. @@ -2909,6 +2923,7 @@ def __new__( func: None = None, *, background: bool | None = None, + cancel_previous_task: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2924,6 +2939,7 @@ def __new__( func: "Callable[[BASE_STATE, Unpack[P]], Any]", *, background: bool | None = None, + cancel_previous_task: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2936,6 +2952,7 @@ def __new__( func: "Callable[[BASE_STATE, Unpack[P]], Any] | None" = None, *, background: bool | None = None, + cancel_previous_task: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2947,6 +2964,7 @@ def __new__( Args: func: The function to wrap. background: Whether the event should be run in the background. Defaults to False. + cancel_previous_task: Whether dispatching this handler cancels its still-running previous run for the same client token. Requires background=True. stop_propagation: Whether to stop the event from bubbling up the DOM tree. prevent_default: Whether to prevent the default behavior of the event. throttle: Throttle the event handler to limit calls (in milliseconds). @@ -2958,6 +2976,7 @@ def __new__( Raises: TypeError: If background is True and the function is not a coroutine or async generator. # noqa: DAR402 + ValueError: If cancel_previous_task is True but background is not True. # noqa: DAR402 """ def _build_event_actions(): @@ -2998,6 +3017,11 @@ def wrapper( msg = "Background task must be async function or generator." raise TypeError(msg) setattr(func, BACKGROUND_TASK_MARKER, True) + if cancel_previous_task: + if background is not True: + msg = "cancel_previous_task=True requires background=True." + raise ValueError(msg) + setattr(func, CANCEL_PREVIOUS_TASK_MARKER, True) if getattr(func, "__name__", "").startswith("_"): msg = "Event handlers cannot be private." raise ValueError(msg) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 7d9296fe4dd..cf18d5d8e85 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -579,6 +579,19 @@ def _create_event_task( Returns: The created asyncio.Task. """ + handler = registered_handler.handler + # For cancel_previous_task handlers, cancel the still-running prior run + # for this same client + handler before starting the new one. The + # CancelledError propagates at the next await inside the handler, + cancel_key: tuple[str, str] | None = None + if handler.is_background and handler.is_cancel_previous_task: + cancel_key = (entry.ctx.token, entry.event.name) + for existing in self._tasks.values(): + if ( + getattr(existing, "_cancel_key", None) == cancel_key + and not existing.done() + ): + existing.cancel() task = asyncio.create_task( self._process_event_queue_entry( entry=entry, registered_handler=registered_handler @@ -587,6 +600,10 @@ def _create_event_task( ) if sys.version_info < (3, 12): task._event_ctx = entry.ctx # pyright: ignore[reportAttributeAccessIssue] + if cancel_key is not None: + # Tag the task so a later run of the same handler can find and + # cancel it via the _tasks scan above. + task._cancel_key = cancel_key # pyright: ignore[reportAttributeAccessIssue] self._tasks[entry.ctx.txid] = task task.add_done_callback(self._finish_task) return task diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 112aa1e8d43..9181a402d77 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -2618,6 +2618,81 @@ async def test_background_task_no_chain(): await bts.bad_chain2() +# Cross-run bookkeeping for the cancel_previous_task test below. These are +# plain module-level lists (not state) so a cancelled run can still record that +# it was cancelled after its state proxy is gone. +cancel_prev_started: list[int] = [] +cancel_prev_cancelled: list[int] = [] +cancel_prev_completed: list[int] = [] +# Holds a single asyncio.Event, created inside the test on the running loop, that +# releases the surviving run once the test is ready for it to finish. +cancel_prev_release: list[asyncio.Event] = [] + + +class CancelPreviousTaskState(BaseState): + """A state whose background task cancels its own previous in-flight run.""" + + runs: int = 0 + + @rx.event(background=True, cancel_previous_task=True) + async def slow_task(self): + """A slow background task that supersedes any still-running prior run.""" + async with self: + self.runs += 1 + run_id = self.runs + cancel_prev_started.append(run_id) + try: + # Stand in for in-flight async work (e.g. a DB query): the await is + # where a superseding run's cancellation lands. + await cancel_prev_release[0].wait() + except asyncio.CancelledError: + cancel_prev_cancelled.append(run_id) + raise + cancel_prev_completed.append(run_id) + + +@pytest.mark.asyncio +async def test_background_task_cancel_previous( + mock_app: rx.App, + token: str, + mock_base_state_event_processor: BaseStateEventProcessor, +): + """Test that cancel_previous_task cancels the still-running prior run. + + Args: + mock_app: An app that will be returned by `get_app()` + token: A token. + mock_base_state_event_processor: The event processor. + """ + cancel_prev_started.clear() + cancel_prev_cancelled.clear() + cancel_prev_completed.clear() + cancel_prev_release.clear() + cancel_prev_release.append(asyncio.Event()) + + event_name = f"{CancelPreviousTaskState.get_full_name()}.slow_task" + settle = 0.5 if CI else 0.2 + async with mock_base_state_event_processor as processor: + # First run starts and parks in its polling loop. + await processor.enqueue(token, Event(name=event_name, payload={})) + await asyncio.sleep(settle) + assert cancel_prev_started == [1] + + # Second run supersedes the first; run 1 should be cancelled. + await processor.enqueue(token, Event(name=event_name, payload={})) + await asyncio.sleep(settle) + assert cancel_prev_started == [1, 2] + assert cancel_prev_cancelled == [1] + + # Let the surviving run finish. + cancel_prev_release[0].set() + await asyncio.sleep(settle) + + # Only the second run completed; the first was cancelled mid-flight. + assert cancel_prev_cancelled == [1] + assert cancel_prev_completed == [2] + + class YieldFromBackgroundState(BaseState): """A state used to verify the type of `self` in a yielded event handler.""" From 8afec8e33d59c6220d1c59214aaf3fc235b7e755 Mon Sep 17 00:00:00 2001 From: Tim Haselhoff Date: Fri, 17 Jul 2026 16:44:14 +0200 Subject: [PATCH 02/10] Cleanup --- packages/reflex-base/src/reflex_base/event/__init__.py | 7 ++----- .../src/reflex_base/event/processor/event_processor.py | 5 ----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 028482c9f62..c3ddc074ff8 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -551,11 +551,8 @@ def is_background(self) -> bool: @property def is_cancel_previous_task(self) -> bool: - """Whether starting this handler cancels its previous in-flight run. - - Only meaningful for background handlers. When True, dispatching a new - run cancels the still-running task from a prior run of the same handler - for the same client token. + """Whether starting this handler cancels its previous run. + Requires the handler to be a background task. Returns: True if the event handler is marked to cancel its previous run. diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index cf18d5d8e85..4d2cad8fd06 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -580,9 +580,6 @@ def _create_event_task( The created asyncio.Task. """ handler = registered_handler.handler - # For cancel_previous_task handlers, cancel the still-running prior run - # for this same client + handler before starting the new one. The - # CancelledError propagates at the next await inside the handler, cancel_key: tuple[str, str] | None = None if handler.is_background and handler.is_cancel_previous_task: cancel_key = (entry.ctx.token, entry.event.name) @@ -601,8 +598,6 @@ def _create_event_task( if sys.version_info < (3, 12): task._event_ctx = entry.ctx # pyright: ignore[reportAttributeAccessIssue] if cancel_key is not None: - # Tag the task so a later run of the same handler can find and - # cancel it via the _tasks scan above. task._cancel_key = cancel_key # pyright: ignore[reportAttributeAccessIssue] self._tasks[entry.ctx.txid] = task task.add_done_callback(self._finish_task) From 350882889d28fa30540f0ce2826c29d7631a256e Mon Sep 17 00:00:00 2001 From: Tim Haselhoff Date: Fri, 17 Jul 2026 16:49:35 +0200 Subject: [PATCH 03/10] More cleanup --- tests/units/test_state.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 9181a402d77..b215f5c4ac3 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -2618,14 +2618,9 @@ async def test_background_task_no_chain(): await bts.bad_chain2() -# Cross-run bookkeeping for the cancel_previous_task test below. These are -# plain module-level lists (not state) so a cancelled run can still record that -# it was cancelled after its state proxy is gone. cancel_prev_started: list[int] = [] cancel_prev_cancelled: list[int] = [] cancel_prev_completed: list[int] = [] -# Holds a single asyncio.Event, created inside the test on the running loop, that -# releases the surviving run once the test is ready for it to finish. cancel_prev_release: list[asyncio.Event] = [] @@ -2642,8 +2637,6 @@ async def slow_task(self): run_id = self.runs cancel_prev_started.append(run_id) try: - # Stand in for in-flight async work (e.g. a DB query): the await is - # where a superseding run's cancellation lands. await cancel_prev_release[0].wait() except asyncio.CancelledError: cancel_prev_cancelled.append(run_id) From 1b7c339a8d156bbcc9ddee46318a82bcceb3d270 Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 19:14:17 +0200 Subject: [PATCH 04/10] Use const for cancel_key attr --- .../src/reflex_base/event/processor/event_processor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 4d2cad8fd06..b1bd03bae1f 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any, TypeVar import rich.markup -from typing_extensions import Self - from reflex.app_mixins.middleware import MiddlewareMixin from reflex.istate.manager import StateManager from reflex.utils import console +from typing_extensions import Self + from reflex_base.event.context import EventContext from reflex_base.event.processor.compat import as_completed from reflex_base.event.processor.future import EventFuture @@ -41,6 +41,7 @@ class QueueShutDown(Exception): # noqa: N818 """Exception raised when trying to put an item into a shut down queue.""" +CANCEL_KEY = "_cancel_key" _StreamItemT = TypeVar("_StreamItemT") @@ -585,7 +586,7 @@ def _create_event_task( cancel_key = (entry.ctx.token, entry.event.name) for existing in self._tasks.values(): if ( - getattr(existing, "_cancel_key", None) == cancel_key + getattr(existing, CANCEL_KEY, None) == cancel_key and not existing.done() ): existing.cancel() @@ -598,7 +599,7 @@ def _create_event_task( if sys.version_info < (3, 12): task._event_ctx = entry.ctx # pyright: ignore[reportAttributeAccessIssue] if cancel_key is not None: - task._cancel_key = cancel_key # pyright: ignore[reportAttributeAccessIssue] + setattr(task, CANCEL_KEY, cancel_key) self._tasks[entry.ctx.txid] = task task.add_done_callback(self._finish_task) return task From 20b88a755d428c2021a6c96f4879e12f1daf15bf Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 19:18:31 +0200 Subject: [PATCH 05/10] Ruffing --- .../src/reflex_base/event/processor/event_processor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index b1bd03bae1f..4f31dadb260 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any, TypeVar import rich.markup +from typing_extensions import Self + from reflex.app_mixins.middleware import MiddlewareMixin from reflex.istate.manager import StateManager from reflex.utils import console -from typing_extensions import Self - from reflex_base.event.context import EventContext from reflex_base.event.processor.compat import as_completed from reflex_base.event.processor.future import EventFuture From 58214a9e811fb636aa55a9bf1ea5a5c9e92c590b Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 19:32:34 +0200 Subject: [PATCH 06/10] Add changelog --- packages/reflex-base/news/6793.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/reflex-base/news/6793.feature.md diff --git a/packages/reflex-base/news/6793.feature.md b/packages/reflex-base/news/6793.feature.md new file mode 100644 index 00000000000..db9f3af82ce --- /dev/null +++ b/packages/reflex-base/news/6793.feature.md @@ -0,0 +1 @@ +Added argument to rx.event to cancel previously scheduled events. From 262fb6c2466b46463d6ed000671e89ff38ae612c Mon Sep 17 00:00:00 2001 From: tim-haselhoff <163506855+tim-haselhoff@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:05:43 +0200 Subject: [PATCH 07/10] Update packages/reflex-base/src/reflex_base/event/processor/event_processor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/reflex_base/event/processor/event_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 4f31dadb260..04abcb7106a 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -41,7 +41,7 @@ class QueueShutDown(Exception): # noqa: N818 """Exception raised when trying to put an item into a shut down queue.""" -CANCEL_KEY = "_cancel_key" +CANCEL_KEY = "_reflex_cancel_key" _StreamItemT = TypeVar("_StreamItemT") From 2a023c048ec60a6dffe3bf41c622164b39eed026 Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 21:26:25 +0200 Subject: [PATCH 08/10] Better performance --- .../event/processor/event_processor.py | 24 +++-- tests/units/test_state.py | 100 +++++++++++------- 2 files changed, 78 insertions(+), 46 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 04abcb7106a..65395319056 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any, TypeVar import rich.markup -from typing_extensions import Self - from reflex.app_mixins.middleware import MiddlewareMixin from reflex.istate.manager import StateManager from reflex.utils import console +from typing_extensions import Self + from reflex_base.event.context import EventContext from reflex_base.event.processor.compat import as_completed from reflex_base.event.processor.future import EventFuture @@ -99,6 +99,7 @@ class EventProcessor: _root_context: The root event context to use for events enqueued without an explicit context. _attached_root_context_token: The context variable token for the attached root context, used to reset the context variable on shutdown. _tasks: A mapping of active transaction ids to their corresponding event handler tasks, used for tracking and cancellation on shutdown. + _cancel_keys: A mapping of ``cancel_previous_task`` keys (token, event name) to the txid of the most recently dispatched task for that key. """ middleware: MiddlewareMixin | None = None @@ -125,6 +126,9 @@ class EventProcessor: str, collections.deque[tuple[EventQueueEntry, RegisteredEventHandler]], ] = dataclasses.field(default_factory=dict, init=False) + _cancel_keys: dict[tuple[str, str], str] = dataclasses.field( + default_factory=dict, init=False + ) def configure( self, @@ -312,6 +316,7 @@ async def stop(self, graceful_shutdown_timeout: float | None = None) -> None: self._queue_task = None # Discard any pending per-token queue entries. self._token_queues.clear() + self._cancel_keys.clear() # Cancel any remaining unresolved futures. for future in self._futures.values(): if not future.done(): @@ -584,11 +589,9 @@ def _create_event_task( cancel_key: tuple[str, str] | None = None if handler.is_background and handler.is_cancel_previous_task: cancel_key = (entry.ctx.token, entry.event.name) - for existing in self._tasks.values(): - if ( - getattr(existing, CANCEL_KEY, None) == cancel_key - and not existing.done() - ): + if (existing_txid := self._cancel_keys.get(cancel_key)) is not None: + existing = self._tasks.get(existing_txid) + if existing is not None and not existing.done(): existing.cancel() task = asyncio.create_task( self._process_event_queue_entry( @@ -600,6 +603,7 @@ def _create_event_task( task._event_ctx = entry.ctx # pyright: ignore[reportAttributeAccessIssue] if cancel_key is not None: setattr(task, CANCEL_KEY, cancel_key) + self._cancel_keys[cancel_key] = entry.ctx.txid self._tasks[entry.ctx.txid] = task task.add_done_callback(self._finish_task) return task @@ -727,6 +731,12 @@ def _finish_task(self, task: asyncio.Task): else: task_ctx = task.get_context().run(EventContext.get) self._tasks.pop(task_ctx.txid, None) + cancel_key = getattr(task, CANCEL_KEY, None) + if ( + cancel_key is not None + and self._cancel_keys.get(cancel_key) == task_ctx.txid + ): + del self._cancel_keys[cancel_key] # Chain the next sequential event for this token if applicable. token_queue = self._token_queues.get(task_ctx.token) if token_queue and token_queue[0][0].ctx.txid == task_ctx.txid: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index b215f5c4ac3..b572fe9b329 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -557,19 +557,23 @@ def test_get_class_substate(): ChildState.get_class_substate((GrandchildState.get_name(),)) == GrandchildState ) assert ( - TestState.get_class_substate(( - ChildState.get_name(), - GrandchildState.get_name(), - )) + TestState.get_class_substate( + ( + ChildState.get_name(), + GrandchildState.get_name(), + ) + ) == GrandchildState ) with pytest.raises(ValueError): TestState.get_class_substate(("invalid_child",)) with pytest.raises(ValueError): - TestState.get_class_substate(( - ChildState.get_name(), - "invalid_child", - )) + TestState.get_class_substate( + ( + ChildState.get_name(), + "invalid_child", + ) + ) def test_get_class_var(): @@ -581,11 +585,13 @@ def test_get_class_var(): assert TestState.get_class_var((ChildState.get_name(), "value")).equals( ChildState.value ) - assert TestState.get_class_var(( - ChildState.get_name(), - GrandchildState.get_name(), - "value2", - )).equals( + assert TestState.get_class_var( + ( + ChildState.get_name(), + GrandchildState.get_name(), + "value2", + ) + ).equals( GrandchildState.value2, ) assert ChildState.get_class_var((GrandchildState.get_name(), "value2")).equals( @@ -594,10 +600,12 @@ def test_get_class_var(): with pytest.raises(ValueError): TestState.get_class_var(("invalid_var",)) with pytest.raises(ValueError): - TestState.get_class_var(( - ChildState.get_name(), - "invalid_var", - )) + TestState.get_class_var( + ( + ChildState.get_name(), + "invalid_var", + ) + ) def test_set_class_var(): @@ -709,11 +717,13 @@ def test_get_substate(test_state, child_state, child_state2, grandchild_state): with pytest.raises(ValueError): test_state.get_substate((ChildState.get_name(), "invalid")) with pytest.raises(ValueError): - test_state.get_substate(( - ChildState.get_name(), - GrandchildState.get_name(), - "invalid", - )) + test_state.get_substate( + ( + ChildState.get_name(), + GrandchildState.get_name(), + "invalid", + ) + ) def test_set_dirty_var(test_state): @@ -2310,11 +2320,13 @@ async def test_state_proxy( assert child_state is not None parent_state = child_state.parent_state assert parent_state is not None - router_data = RouterData.from_router_data({ - "query": {}, - "token": token, - "sid": "test_sid", - }) + router_data = RouterData.from_router_data( + { + "query": {}, + "token": token, + "sid": "test_sid", + } + ) grandchild_state.router = router_data state_manager = attached_mock_event_context.state_manager if isinstance(state_manager, (StateManagerMemory, StateManagerDisk)): @@ -2671,16 +2683,22 @@ async def test_background_task_cancel_previous( await asyncio.sleep(settle) assert cancel_prev_started == [1] + assert (token, event_name) in processor._cancel_keys + assert len(processor._cancel_keys) == 1 + # Second run supersedes the first; run 1 should be cancelled. await processor.enqueue(token, Event(name=event_name, payload={})) await asyncio.sleep(settle) assert cancel_prev_started == [1, 2] assert cancel_prev_cancelled == [1] + assert len(processor._cancel_keys) == 1 # Let the surviving run finish. cancel_prev_release[0].set() await asyncio.sleep(settle) + assert processor._cancel_keys == {} + # Only the second run completed; the first was cancelled mid-flight. assert cancel_prev_cancelled == [1] assert cancel_prev_completed == [2] @@ -3613,9 +3631,9 @@ async def test_get_state(token: str, attached_mock_event_context: EventContext): assert child_state_direct is child_state_get_state # GrandchildState instance should be the same as the one retrieved from the child_state2. - assert grandchild_state is child_state_direct.get_substate([ - GrandchildState.get_name() - ]) + assert grandchild_state is child_state_direct.get_substate( + [GrandchildState.get_name()] + ) grandchild_state.value2 = "set_value" assert test_state.get_delta() == { @@ -3839,10 +3857,12 @@ async def test_setvar( mock_base_state_event_processor: The event processor. """ # Set Var in same state (with Var type casting) - events = Event.from_event_type([ - TestState.set_num1(42), - TestState.set_num2(4.2), - ]) + events = Event.from_event_type( + [ + TestState.set_num1(42), + TestState.set_num2(4.2), + ] + ) async with mock_base_state_event_processor as processor: for fut in asyncio.as_completed(await processor.enqueue_many(token, *events)): await fut @@ -5127,11 +5147,13 @@ async def test_rebind_mutable_proxy( async with state_manager.modify_state( BaseStateToken(ident=token, cls=MutableProxyState) ) as state: - state.router = RouterData.from_router_data({ - "query": {}, - "token": token, - "sid": "test_sid", - }) + state.router = RouterData.from_router_data( + { + "query": {}, + "token": token, + "sid": "test_sid", + } + ) assert isinstance(state, MutableProxyState) assert isinstance(state.data, MutableProxy) assert not isinstance(state.data, ImmutableMutableProxy) From 92a7790f8b8d3933e98a433648e282dd850f00d8 Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 23:47:37 +0200 Subject: [PATCH 09/10] Additional comment & Ruffing --- .../src/reflex_base/event/__init__.py | 2 +- .../event/processor/event_processor.py | 4 +- tests/units/test_state.py | 94 ++++++++----------- 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index c3ddc074ff8..8f258e08837 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -2961,7 +2961,7 @@ def __new__( Args: func: The function to wrap. background: Whether the event should be run in the background. Defaults to False. - cancel_previous_task: Whether dispatching this handler cancels its still-running previous run for the same client token. Requires background=True. + cancel_previous_task: Whether dispatching this handler cancels its still-running previous run for the same client token. Requires background=True. NOTE: This can cause partial state updates if previous task always modified state before cancelation. stop_propagation: Whether to stop the event from bubbling up the DOM tree. prevent_default: Whether to prevent the default behavior of the event. throttle: Throttle the event handler to limit calls (in milliseconds). diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 65395319056..cbe65efe83a 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any, TypeVar import rich.markup +from typing_extensions import Self + from reflex.app_mixins.middleware import MiddlewareMixin from reflex.istate.manager import StateManager from reflex.utils import console -from typing_extensions import Self - from reflex_base.event.context import EventContext from reflex_base.event.processor.compat import as_completed from reflex_base.event.processor.future import EventFuture diff --git a/tests/units/test_state.py b/tests/units/test_state.py index b572fe9b329..ebcc20f8b52 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -557,23 +557,19 @@ def test_get_class_substate(): ChildState.get_class_substate((GrandchildState.get_name(),)) == GrandchildState ) assert ( - TestState.get_class_substate( - ( - ChildState.get_name(), - GrandchildState.get_name(), - ) - ) + TestState.get_class_substate(( + ChildState.get_name(), + GrandchildState.get_name(), + )) == GrandchildState ) with pytest.raises(ValueError): TestState.get_class_substate(("invalid_child",)) with pytest.raises(ValueError): - TestState.get_class_substate( - ( - ChildState.get_name(), - "invalid_child", - ) - ) + TestState.get_class_substate(( + ChildState.get_name(), + "invalid_child", + )) def test_get_class_var(): @@ -585,13 +581,11 @@ def test_get_class_var(): assert TestState.get_class_var((ChildState.get_name(), "value")).equals( ChildState.value ) - assert TestState.get_class_var( - ( - ChildState.get_name(), - GrandchildState.get_name(), - "value2", - ) - ).equals( + assert TestState.get_class_var(( + ChildState.get_name(), + GrandchildState.get_name(), + "value2", + )).equals( GrandchildState.value2, ) assert ChildState.get_class_var((GrandchildState.get_name(), "value2")).equals( @@ -600,12 +594,10 @@ def test_get_class_var(): with pytest.raises(ValueError): TestState.get_class_var(("invalid_var",)) with pytest.raises(ValueError): - TestState.get_class_var( - ( - ChildState.get_name(), - "invalid_var", - ) - ) + TestState.get_class_var(( + ChildState.get_name(), + "invalid_var", + )) def test_set_class_var(): @@ -717,13 +709,11 @@ def test_get_substate(test_state, child_state, child_state2, grandchild_state): with pytest.raises(ValueError): test_state.get_substate((ChildState.get_name(), "invalid")) with pytest.raises(ValueError): - test_state.get_substate( - ( - ChildState.get_name(), - GrandchildState.get_name(), - "invalid", - ) - ) + test_state.get_substate(( + ChildState.get_name(), + GrandchildState.get_name(), + "invalid", + )) def test_set_dirty_var(test_state): @@ -2320,13 +2310,11 @@ async def test_state_proxy( assert child_state is not None parent_state = child_state.parent_state assert parent_state is not None - router_data = RouterData.from_router_data( - { - "query": {}, - "token": token, - "sid": "test_sid", - } - ) + router_data = RouterData.from_router_data({ + "query": {}, + "token": token, + "sid": "test_sid", + }) grandchild_state.router = router_data state_manager = attached_mock_event_context.state_manager if isinstance(state_manager, (StateManagerMemory, StateManagerDisk)): @@ -3631,9 +3619,9 @@ async def test_get_state(token: str, attached_mock_event_context: EventContext): assert child_state_direct is child_state_get_state # GrandchildState instance should be the same as the one retrieved from the child_state2. - assert grandchild_state is child_state_direct.get_substate( - [GrandchildState.get_name()] - ) + assert grandchild_state is child_state_direct.get_substate([ + GrandchildState.get_name() + ]) grandchild_state.value2 = "set_value" assert test_state.get_delta() == { @@ -3857,12 +3845,10 @@ async def test_setvar( mock_base_state_event_processor: The event processor. """ # Set Var in same state (with Var type casting) - events = Event.from_event_type( - [ - TestState.set_num1(42), - TestState.set_num2(4.2), - ] - ) + events = Event.from_event_type([ + TestState.set_num1(42), + TestState.set_num2(4.2), + ]) async with mock_base_state_event_processor as processor: for fut in asyncio.as_completed(await processor.enqueue_many(token, *events)): await fut @@ -5147,13 +5133,11 @@ async def test_rebind_mutable_proxy( async with state_manager.modify_state( BaseStateToken(ident=token, cls=MutableProxyState) ) as state: - state.router = RouterData.from_router_data( - { - "query": {}, - "token": token, - "sid": "test_sid", - } - ) + state.router = RouterData.from_router_data({ + "query": {}, + "token": token, + "sid": "test_sid", + }) assert isinstance(state, MutableProxyState) assert isinstance(state.data, MutableProxy) assert not isinstance(state.data, ImmutableMutableProxy) From b419ff6b98aab705bff1fb329903b36f470eb848 Mon Sep 17 00:00:00 2001 From: tim-haselhoff Date: Fri, 17 Jul 2026 23:51:39 +0200 Subject: [PATCH 10/10] make codespell happy --- packages/reflex-base/src/reflex_base/event/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 8f258e08837..4a4f131db31 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -2961,7 +2961,7 @@ def __new__( Args: func: The function to wrap. background: Whether the event should be run in the background. Defaults to False. - cancel_previous_task: Whether dispatching this handler cancels its still-running previous run for the same client token. Requires background=True. NOTE: This can cause partial state updates if previous task always modified state before cancelation. + cancel_previous_task: Whether dispatching this handler cancels its still-running previous run for the same client token. Requires background=True. NOTE: This can cause partial state updates if previous task always modified state before cancellation. stop_propagation: Whether to stop the event from bubbling up the DOM tree. prevent_default: Whether to prevent the default behavior of the event. throttle: Throttle the event handler to limit calls (in milliseconds).