88 create_task ,
99 current_task ,
1010 get_running_loop ,
11+ sleep ,
1112 wait ,
1213)
1314from collections import Counter
@@ -65,6 +66,8 @@ def __init__(self, root: Component | Context[Any] | ContextProvider[Any]) -> Non
6566 async def __aenter__ (self ) -> Layout :
6667 # create attributes here to avoid access before entering context manager
6768 self ._event_handlers : EventHandlerDict = {}
69+ self ._event_queues : dict [str , Queue [LayoutEventMessage | dict [str , Any ]]] = {}
70+ self ._event_processing_tasks : dict [str , Task [None ]] = {}
6871 self ._render_tasks : set [Task [LayoutUpdateMessage ]] = set ()
6972 self ._render_tasks_by_id : dict [
7073 _LifeCycleStateId , Task [LayoutUpdateMessage ]
@@ -88,10 +91,18 @@ async def __aexit__(
8891 t .cancel ()
8992 with suppress (CancelledError ):
9093 await t
94+
95+ for t in self ._event_processing_tasks .values ():
96+ t .cancel ()
97+ with suppress (CancelledError ):
98+ await t
99+
91100 await self ._unmount_model_states ([root_model_state ])
92101
93102 # delete attributes here to avoid access after exiting context manager
94103 del self ._event_handlers
104+ del self ._event_queues
105+ del self ._event_processing_tasks
95106 del self ._rendering_queue
96107 del self ._render_tasks_by_id
97108 del self ._root_life_cycle_state_id
@@ -103,20 +114,51 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None:
103114 # associated with a backend model that has been deleted. We only handle
104115 # events if the element and the handler exist in the backend. Otherwise
105116 # we just ignore the event.
106- handler = self ._event_handlers .get (event ["target" ])
107-
108- if handler is not None :
109- try :
110- data = [Event (d ) if isinstance (d , dict ) else d for d in event ["data" ]]
111- await handler .function (data )
112- except Exception :
113- logger .exception (f"Failed to execute event handler { handler } " )
114- else :
115- logger .info (
116- f"Ignored event - handler { event ['target' ]!r} "
117- "does not exist or its component unmounted"
117+ target = event ["target" ]
118+ if target not in self ._event_queues :
119+ self ._event_queues [target ] = cast (
120+ "Queue[LayoutEventMessage | dict[str, Any]]" , Queue ()
121+ )
122+ self ._event_processing_tasks [target ] = create_task (
123+ self ._process_event_queue (target , self ._event_queues [target ])
118124 )
119125
126+ await self ._event_queues [target ].put (event )
127+
128+ # In test environments, we yield to the event loop to let the processing tasks run.
129+ if REACTPY_DEBUG .current :
130+ await sleep (0 )
131+
132+ async def _process_event_queue (
133+ self , target : str , queue : Queue [LayoutEventMessage | dict [str , Any ]]
134+ ) -> None :
135+ while True :
136+ event = await queue .get ()
137+
138+ # Retry a few times to handle potential re-render race conditions where
139+ # the handler is temporarily removed and then re-added.
140+ handler = self ._event_handlers .get (target )
141+ if handler is None :
142+ for _ in range (3 ):
143+ await sleep (0.01 )
144+ handler = self ._event_handlers .get (target )
145+ if handler is not None :
146+ break
147+
148+ if handler is not None :
149+ try :
150+ data = [
151+ Event (d ) if isinstance (d , dict ) else d for d in event ["data" ]
152+ ]
153+ await handler .function (data )
154+ except Exception :
155+ logger .exception (f"Failed to execute event handler { handler } " )
156+ else :
157+ logger .info (
158+ f"Ignored event - handler { event ['target' ]!r} "
159+ "does not exist or its component unmounted"
160+ )
161+
120162 async def render (self ) -> LayoutUpdateMessage :
121163 if REACTPY_ASYNC_RENDERING .current :
122164 return await self ._parallel_render ()
@@ -346,10 +388,11 @@ def _render_model_attributes(
346388
347389 model_event_handlers = new_state .model .current ["eventHandlers" ] = {}
348390 for event , handler in handlers_by_event .items ():
349- if event in old_state . targets_by_event :
350- target = old_state . targets_by_event [ event ]
391+ if handler . target is not None :
392+ target = handler . target
351393 else :
352- target = uuid4 ().hex if handler .target is None else handler .target
394+ target = f"{ new_state .key_path } :{ event } "
395+
353396 new_state .targets_by_event [event ] = target
354397 self ._event_handlers [target ] = handler
355398 model_event_handlers [event ] = {
@@ -370,7 +413,11 @@ def _render_model_event_handlers_without_old_state(
370413
371414 model_event_handlers = new_state .model .current ["eventHandlers" ] = {}
372415 for event , handler in handlers_by_event .items ():
373- target = uuid4 ().hex if handler .target is None else handler .target
416+ if handler .target is not None :
417+ target = handler .target
418+ else :
419+ target = f"{ new_state .key_path } :{ event } "
420+
374421 new_state .targets_by_event [event ] = target
375422 self ._event_handlers [target ] = handler
376423 model_event_handlers [event ] = {
@@ -485,6 +532,7 @@ def _new_root_model_state(
485532 children_by_key = {},
486533 targets_by_event = {},
487534 life_cycle_state = _make_life_cycle_state (component , schedule_render ),
535+ key_path = "" ,
488536 )
489537
490538
@@ -504,6 +552,7 @@ def _make_component_model_state(
504552 children_by_key = {},
505553 targets_by_event = {},
506554 life_cycle_state = _make_life_cycle_state (component , schedule_render ),
555+ key_path = f"{ parent .key_path } /{ key } " if parent else "" ,
507556 )
508557
509558
@@ -523,6 +572,7 @@ def _copy_component_model_state(old_model_state: _ModelState) -> _ModelState:
523572 children_by_key = {},
524573 targets_by_event = {},
525574 life_cycle_state = old_model_state .life_cycle_state ,
575+ key_path = old_model_state .key_path ,
526576 )
527577
528578
@@ -546,6 +596,7 @@ def _update_component_model_state(
546596 if old_model_state .is_component_state
547597 else _make_life_cycle_state (new_component , schedule_render )
548598 ),
599+ key_path = f"{ new_parent .key_path } /{ old_model_state .key } " ,
549600 )
550601
551602
@@ -562,6 +613,7 @@ def _make_element_model_state(
562613 patch_path = f"{ parent .patch_path } /children/{ index } " ,
563614 children_by_key = {},
564615 targets_by_event = {},
616+ key_path = f"{ parent .key_path } /{ key } " ,
565617 )
566618
567619
@@ -575,9 +627,10 @@ def _update_element_model_state(
575627 index = new_index ,
576628 key = old_model_state .key ,
577629 model = Ref (), # does not copy the model
578- patch_path = old_model_state .patch_path ,
630+ patch_path = f" { new_parent .patch_path } /children/ { new_index } " ,
579631 children_by_key = {},
580632 targets_by_event = {},
633+ key_path = f"{ new_parent .key_path } /{ old_model_state .key } " ,
581634 )
582635
583636
@@ -591,6 +644,7 @@ class _ModelState:
591644 "children_by_key" ,
592645 "index" ,
593646 "key" ,
647+ "key_path" ,
594648 "life_cycle_state" ,
595649 "model" ,
596650 "patch_path" ,
@@ -607,6 +661,7 @@ def __init__(
607661 children_by_key : dict [Key , _ModelState ],
608662 targets_by_event : dict [str , str ],
609663 life_cycle_state : _LifeCycleState | None = None ,
664+ key_path : str = "" ,
610665 ):
611666 self .index = index
612667 """The index of the element amongst its siblings"""
@@ -626,6 +681,9 @@ def __init__(
626681 self .targets_by_event = targets_by_event
627682 """The element's event handler target strings indexed by their event name"""
628683
684+ self .key_path = key_path
685+ """A slash-delimited path using element keys"""
686+
629687 # === Conditionally Available Attributes ===
630688 # It's easier to conditionally assign than to force a null check on every usage
631689
0 commit comments