Skip to content

Commit 54e0d54

Browse files
committed
Add event queue, fix #1320
1 parent de279e5 commit 54e0d54

File tree

4 files changed

+252
-24
lines changed

4 files changed

+252
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Don't forget to remove deprecated code on each major release!
5252
- Rewrite the `event-to-object` package to be more robust at handling properties on events.
5353
- Custom JS components will now automatically assume you are using ReactJS in the absence of a `bind` function.
5454
- Refactor layout rendering logic to improve readability and maintainability.
55-
- `@reactpy/client` now exports `React` and `ReactDOM`.
55+
- The JavaScript package `@reactpy/client` now exports `React` and `ReactDOM`, which allows third-party components to re-use the same React instance as ReactPy.
5656
- `reactpy.html` will now automatically flatten lists recursively (ex. `reactpy.html(["child1", ["child2"]])`)
5757
- `reactpy.utils.reactpy_to_string` will now retain the user's original casing for `data-*` and `aria-*` attributes.
5858
- `reactpy.utils.string_to_reactpy` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors.

src/reactpy/core/layout.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
create_task,
99
current_task,
1010
get_running_loop,
11+
sleep,
1112
wait,
1213
)
1314
from 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

tests/test_core/test_events.py

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import asyncio
2+
from functools import partial
3+
14
import pytest
25

36
import reactpy
4-
from functools import partial
5-
from reactpy import component, html
7+
from reactpy import component, html, use_state
68
from reactpy.core.events import (
79
EventHandler,
810
merge_event_handler_funcs,
@@ -419,3 +421,171 @@ def handler(e: Event):
419421
eh = EventHandler(handler)
420422
assert eh.prevent_default is True
421423
assert eh.stop_propagation is True
424+
425+
426+
async def test_event_queue_sequential_processing(display: DisplayFixture):
427+
"""Ensure events are processed sequentially for the same target"""
428+
429+
events_processed = []
430+
431+
@component
432+
def SequentialEvents():
433+
async def handle_click(event):
434+
# Simulate slow processing
435+
await asyncio.sleep(0.1)
436+
events_processed.append(event["target"])
437+
438+
return html.button({"id": "btn", "onClick": handle_click}, "Click me")
439+
440+
await display.show(SequentialEvents)
441+
442+
# Get the element
443+
btn = display.page.locator("#btn")
444+
445+
# Click 3 times rapidly
446+
# We use evaluate to trigger clicks rapidly from client side perspective if possible,
447+
# or just click rapidly via playwright.
448+
# Playwright's click is awaited, so we need to run them concurrently.
449+
450+
await asyncio.gather(
451+
btn.click(),
452+
btn.click(),
453+
btn.click(),
454+
)
455+
456+
# Wait for processing to complete (0.1s * 3 = 0.3s approx)
457+
await asyncio.sleep(0.5)
458+
459+
assert len(events_processed) == 3
460+
461+
462+
async def test_event_targeting_with_shifting_elements(display: DisplayFixture):
463+
"""
464+
Ensure that events are delivered to the correct component even when
465+
elements shift around it, provided explicit keys are used.
466+
"""
467+
468+
clicked_items = []
469+
470+
@component
471+
def Item(id_val):
472+
async def handle_click(event):
473+
clicked_items.append(id_val)
474+
475+
return html.div(
476+
{"id": f"item-{id_val}", "onClick": handle_click}, f"Item {id_val}"
477+
)
478+
479+
@component
480+
def ListContainer():
481+
items, set_items = use_state(["B", "C"])
482+
483+
def add_top(event):
484+
set_items(["A", *items])
485+
486+
return html.div(
487+
html.button({"id": "add-btn", "onClick": add_top}, "Add Top"),
488+
html.div({"id": "list"}, [Item(i, key=i) for i in items]),
489+
)
490+
491+
await display.show(ListContainer)
492+
493+
# Initial state: Items B, C are present.
494+
# Click Item B.
495+
btn_b = display.page.locator("#item-B")
496+
await btn_b.click()
497+
498+
# Add Item A to the top.
499+
add_btn = display.page.locator("#add-btn")
500+
await add_btn.click()
501+
502+
# Wait for Item A to appear to ensure render is complete
503+
await display.page.locator("#item-A").wait_for()
504+
505+
# Now the list is [A, B, C].
506+
# Item B has shifted position in the DOM (index 0 -> index 1).
507+
# Its key path should remain .../B regardless of index if we implemented it right?
508+
# Actually, let's verify how key_path is constructed.
509+
# In layout.py: key_path=f"{parent.key_path}/{key}"
510+
# So if the parent is the div container, and items have keys "A", "B", "C".
511+
# The paths are .../list/A, .../list/B, .../list/C.
512+
# The index in the children array changes, but the key_path relies on the key, not the index (if key is provided).
513+
514+
# Click Item B again.
515+
# It should still trigger the handler for B, not A (which is now at index 0) and not C.
516+
await btn_b.click()
517+
518+
# Click Item C.
519+
btn_c = display.page.locator("#item-C")
520+
await btn_c.click()
521+
522+
# Assertions
523+
# We expect 'B' (first click), then 'B' (second click after shift), then 'C'.
524+
assert clicked_items == ["B", "B", "C"]
525+
526+
527+
async def test_event_targeting_with_index_shifting(display: DisplayFixture):
528+
"""
529+
Ensure that when keys are NOT provided (using indices),
530+
events might target the element at the same *index* if the user isn't careful,
531+
but we verify that the system behaves predictably (target is based on path).
532+
533+
If we insert at top without keys:
534+
Old: Index 0 (Item B) -> Path .../0
535+
New: Index 0 (Item A), Index 1 (Item B) -> Path .../0 turns into Item A.
536+
537+
If an event was in-flight for Index 0 (Item B) when the update happened:
538+
The event target ID was ".../0:click".
539+
After update, ".../0:click" is now Item A's handler.
540+
541+
So the event intended for B would execute on A. This is standard React behavior for index keys.
542+
We just want to ensure our system works this way and doesn't crash or lose the event.
543+
"""
544+
545+
clicked_items = []
546+
547+
@component
548+
def Item(id_val):
549+
async def handle_click(event):
550+
clicked_items.append(id_val)
551+
552+
return html.div(
553+
{"id": f"item-{id_val}", "onClick": handle_click}, f"Item {id_val}"
554+
)
555+
556+
@component
557+
def ListContainer():
558+
items, set_items = use_state(["B"])
559+
560+
async def add_top(event):
561+
set_items(["A", *items])
562+
# We want to create a race condition where we click Item B (index 0)
563+
# just narrowly before the re-render places Item A at index 0.
564+
# But 'display.show' and playwright interactions are sequential usually.
565+
# We can simulate the state change.
566+
567+
return html.div(
568+
html.button({"id": "add-btn", "onClick": add_top}, "Add Top"),
569+
html.div({"id": "list"}, [Item(i) for i in items]),
570+
)
571+
572+
await display.show(ListContainer)
573+
574+
# Initial: Item B at Index 0.
575+
# We want to send an event to Index 0 *effectively*, but have it process *after* A is inserted at Index 0.
576+
# This is hard to orchestrate with exact timing in an integration test without hooks into the internal loop.
577+
# However, we can verifying that basic interaction works after the shift.
578+
579+
add_btn = display.page.locator("#add-btn")
580+
await add_btn.click()
581+
582+
await display.page.locator("#item-A").wait_for()
583+
584+
# Now Item A is at Index 0 (".../0"). Item B is at Index 1 (".../1").
585+
# Clicking Item B should technically work fine because we are clicking the DOM element for B,
586+
# which should generate an event for target ".../1".
587+
588+
btn_b = display.page.locator("#item-B")
589+
await btn_b.click() # This generates event for .../1
590+
591+
assert clicked_items == ["B"]

0 commit comments

Comments
 (0)