diff --git a/packages/reflex-base/news/6801.bugfix.md b/packages/reflex-base/news/6801.bugfix.md new file mode 100644 index 00000000000..41bc88f9f0b --- /dev/null +++ b/packages/reflex-base/news/6801.bugfix.md @@ -0,0 +1 @@ +Enqueuing an event chained from a parent event that has already finished no longer raises `RuntimeError: Cannot add a child to an EventFuture that is already done.`. Such a late-chained event skips registration under the completed parent and is processed normally. 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 fcdc90eade5..8afc7b2fb54 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 @@ -423,7 +423,10 @@ async def enqueue( # tracker since this event will never enter the queue. tracked.cancel() return tracked - parent_future.add_child(tracked) + # Skip registration if the parent is already done (late-chained + # event) so the child runs instead of crashing. + if not parent_future.done(): + parent_future.add_child(tracked) if parent_future is None: self._supersede_previous(token=token, event=event, tracked=tracked) await queue.put(EventQueueEntry(event=event, ctx=ev_ctx)) diff --git a/tests/units/reflex_base/event/processor/test_event_processor.py b/tests/units/reflex_base/event/processor/test_event_processor.py index a66de81086e..417bbdc6008 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import dataclasses import logging from typing import Any @@ -13,6 +14,7 @@ QueueShutDown, _stream_queue_until_done, ) +from reflex_base.event.processor.future import EventFuture from reflex_base.registry import RegistrationContext from reflex.event import Event, EventHandler @@ -622,6 +624,38 @@ async def test_chained_event_processed(token: str): assert _CALL_LOG == [{"value": "chained"}] +async def test_enqueue_child_of_done_parent_does_not_crash( + mock_event_processor: EventProcessor, + token: str, +): + """Regression: a late-chained event whose parent future already completed + still runs instead of crashing when registered as the parent's child. + + Args: + mock_event_processor: The event processor with mock root context. + token: The client token. + """ + async with mock_event_processor as ep: + done_parent = EventFuture(txid="parent-txid") + done_parent.set_result(None) + ep._futures["parent-txid"] = done_parent + + assert ep._root_context is not None + child_ctx = dataclasses.replace( + ep._root_context.fork(token=token), parent_txid="parent-txid" + ) + future = await ep.enqueue( + token, + Event.from_event_type(logging_event("late-child"))[0], + ev_ctx=child_ctx, + ) + await future + + assert _CALL_LOG == [{"value": "late-child"}] + # The child is not registered under the already-done parent. + assert done_parent.children == [] + + async def test_join_when_not_started(processor: EventProcessor): """join() when not started is a no-op (queue is None).