Skip to content

Commit 2a2e9ea

Browse files
authored
Add event queue (#1338)
1 parent de279e5 commit 2a2e9ea

File tree

24 files changed

+398
-73
lines changed

24 files changed

+398
-73
lines changed

.github/workflows/check.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ jobs:
1515
uses: ./.github/workflows/.hatch-run.yml
1616
with:
1717
job-name: "python-{0}"
18-
run-cmd: "hatch test --parallel --cover"
18+
# Retries needed because GitHub workers sometimes lag enough to crash parallel workers
19+
run-cmd: "hatch test --parallel --cover --retries 10"
1920
lint-python:
2021
uses: ./.github/workflows/.hatch-run.yml
2122
with:
@@ -25,7 +26,7 @@ jobs:
2526
uses: ./.github/workflows/.hatch-run.yml
2627
with:
2728
job-name: "python-{0} {1}"
28-
run-cmd: "hatch test --parallel"
29+
run-cmd: "hatch test --parallel --retries 10"
2930
runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]'
3031
python-version: '["3.11", "3.12", "3.13", "3.14"]'
3132
test-documentation:

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/js/packages/@reactpy/client/src/bind.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ export async function infer_bind_from_environment() {
88
const ReactDOM = await import("react-dom/client");
99
return (node: HTMLElement) => reactjs_bind(node, React, ReactDOM);
1010
} catch {
11-
console.error(
12-
"Unknown error occurred: 'react' is missing within this ReactPy environment! \
13-
Your JavaScript components may not work as expected!",
11+
console.debug(
12+
"ReactPy will render JavaScript components using internal bindings for 'react'.",
1413
);
1514
return (node: HTMLElement) => local_preact_bind(node);
1615
}

src/js/packages/@reactpy/client/src/client.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class ReactPyClient
5959
urls: ReactPyUrls;
6060
socket: { current?: WebSocket };
6161
mountElement: HTMLElement;
62+
private readonly messageQueue: any[] = [];
6263

6364
constructor(props: GenericReactPyClientProps) {
6465
super();
@@ -69,12 +70,24 @@ export class ReactPyClient
6970
url: this.urls.componentUrl,
7071
readyPromise: this.ready,
7172
...props.reconnectOptions,
73+
onOpen: () => {
74+
while (this.messageQueue.length > 0) {
75+
this.sendMessage(this.messageQueue.shift());
76+
}
77+
},
7278
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
7379
});
7480
}
7581

7682
sendMessage(message: any): void {
77-
this.socket.current?.send(JSON.stringify(message));
83+
if (
84+
this.socket.current &&
85+
this.socket.current.readyState === WebSocket.OPEN
86+
) {
87+
this.socket.current.send(JSON.stringify(message));
88+
} else {
89+
this.messageQueue.push(message);
90+
}
7891
}
7992

8093
loadModule(moduleName: string): Promise<ReactPyModule> {

src/reactpy/core/hooks.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,6 @@ def decorator(func: _SyncEffectFunc) -> None:
155155
)
156156

157157
async def effect(stop: asyncio.Event) -> None:
158-
# Since the effect is asynchronous, we need to make sure we
159-
# always clean up the previous effect's resources
160158
run_effect_cleanup(cleanup_func)
161159

162160
# Execute the effect and store the clean-up function
@@ -219,17 +217,23 @@ def use_async_effect(
219217
dependencies = _try_to_infer_closure_values(function, dependencies)
220218
memoize = use_memo(dependencies=dependencies)
221219
cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None)
220+
pending_task: Ref[asyncio.Task[_EffectCleanFunc | None] | None] = use_ref(None)
222221

223222
def decorator(func: _AsyncEffectFunc) -> None:
224223
async def effect(stop: asyncio.Event) -> None:
225-
# Since the effect is asynchronous, we need to make sure we
226-
# always clean up the previous effect's resources
224+
# Make sure we always clean up the previous effect's resources
225+
if pending_task.current:
226+
pending_task.current.cancel()
227+
with contextlib.suppress(asyncio.CancelledError):
228+
await pending_task.current
229+
227230
run_effect_cleanup(cleanup_func)
228231

229232
# Execute the effect and store the clean-up function.
230233
# We run this in a task so it can be cancelled if the stop signal
231234
# is set before the effect completes.
232235
task = asyncio.create_task(func())
236+
pending_task.current = task
233237

234238
# Wait for either the effect to complete or the stop signal
235239
stop_task = asyncio.create_task(stop.wait())
@@ -240,15 +244,17 @@ async def effect(stop: asyncio.Event) -> None:
240244

241245
# If the effect completed first, store the cleanup function
242246
if task in done:
243-
cleanup_func.current = task.result()
247+
pending_task.current = None
248+
with contextlib.suppress(asyncio.CancelledError):
249+
cleanup_func.current = task.result()
244250
# Cancel the stop task since we don't need it anymore
245251
stop_task.cancel()
246252
with contextlib.suppress(asyncio.CancelledError):
247253
await stop_task
248254
# Now wait for the stop signal to run cleanup
249255
await stop.wait()
256+
# Stop signal came first - cancel the effect task
250257
else:
251-
# Stop signal came first - cancel the effect task
252258
task.cancel()
253259
with contextlib.suppress(asyncio.CancelledError):
254260
await task

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/__init__.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +0,0 @@
1-
import pytest
2-
3-
from reactpy.testing import GITHUB_ACTIONS
4-
5-
pytestmark = [pytest.mark.flaky(reruns=10 if GITHUB_ACTIONS else 1)]

tests/conftest.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
)
1616
from reactpy.testing.display import _playwright_visible
1717

18-
from . import pytestmark # noqa: F401
19-
2018
REACTPY_ASYNC_RENDERING.set_current(True)
2119
REACTPY_DEBUG.set_current(True)
2220

tests/test_asgi/test_middleware.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
from reactpy.executors.asgi.middleware import ReactPyMiddleware
1616
from reactpy.testing import BackendFixture, DisplayFixture
1717

18-
from .. import pytestmark # noqa: F401
19-
2018

2119
@pytest.fixture(scope="module")
2220
async def display(browser):

tests/test_asgi/test_pyscript.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
from reactpy.executors.asgi.pyscript import ReactPyCsr
1313
from reactpy.testing import BackendFixture, DisplayFixture
1414

15-
from .. import pytestmark # noqa: F401
16-
1715

1816
@pytest.fixture(scope="module")
1917
async def display(browser):

0 commit comments

Comments
 (0)