Skip to content

Commit e5655d0

Browse files
committed
support concurrent renders
1 parent 016b54d commit e5655d0

File tree

5 files changed

+90
-39
lines changed

5 files changed

+90
-39
lines changed

src/py/reactpy/reactpy/core/_life_cycle_hook.py

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from collections.abc import Coroutine
66
from typing import Any, Callable, TypeVar
77

8+
from anyio import Semaphore
9+
810
from reactpy.core._thread_local import ThreadLocal
911
from reactpy.core.types import ComponentType, Context, ContextProviderType
1012

@@ -93,6 +95,7 @@ class LifeCycleHook:
9395
"_effect_starts",
9496
"_effect_stops",
9597
"_is_rendering",
98+
"_render_access",
9699
"_rendered_atleast_once",
97100
"_schedule_render_callback",
98101
"_schedule_render_later",
@@ -115,6 +118,7 @@ def __init__(
115118
self._state: tuple[Any, ...] = ()
116119
self._effect_starts: list[StartEffect] = []
117120
self._effect_stops: list[StopEffect] = []
121+
self._render_access = Semaphore(1) # ensure only one render at a time
118122

119123
def schedule_render(self) -> None:
120124
if self._is_rendering:
@@ -147,6 +151,7 @@ def get_context_provider(
147151

148152
async def affect_component_will_render(self, component: ComponentType) -> None:
149153
"""The component is about to render"""
154+
await self._render_access.acquire()
150155
self.component = component
151156
self._is_rendering = True
152157
self.set_current()
@@ -158,6 +163,7 @@ async def affect_component_did_render(self) -> None:
158163
self._is_rendering = False
159164
self._rendered_atleast_once = True
160165
self._current_state_index = 0
166+
self._render_access.release()
161167

162168
async def affect_layout_did_render(self) -> None:
163169
"""The layout completed a render"""

src/py/reactpy/reactpy/core/hooks.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,18 @@ def use_effect(
155155
def add_effect(function: _Effect) -> None:
156156
effect_func = _cast_async_effect(function)
157157

158-
async def start_effect() -> Callable:
158+
async def start_effect() -> StopEffect:
159159
if stop_last_effect.current is not None:
160160
await stop_last_effect.current()
161161

162162
stop = Event()
163163

164-
async def run_effect() -> StopEffect:
164+
async def run_effect() -> None:
165165
effect_gen = effect_func()
166166
# start running the effect
167-
effect_task = create_task(effect_gen.asend(None))
167+
effect_task = create_task(
168+
cast(Coroutine[None, None, None], effect_gen.asend(None))
169+
)
168170
# wait for re-render or unmount
169171
await stop.wait()
170172
# signal effect to stop (no-op if already complete)

src/py/reactpy/reactpy/core/layout.py

+56-23
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
from __future__ import annotations
22

33
import abc
4-
import asyncio
4+
from asyncio import (
5+
FIRST_COMPLETED,
6+
Event,
7+
Queue,
8+
Task,
9+
create_task,
10+
gather,
11+
get_running_loop,
12+
wait,
13+
)
514
from collections import Counter
615
from collections.abc import Iterator
716
from contextlib import AsyncExitStack
@@ -41,6 +50,7 @@ class Layout:
4150
"root",
4251
"_event_handlers",
4352
"_rendering_queue",
53+
"_render_tasks",
4454
"_root_life_cycle_state_id",
4555
"_model_states_by_life_cycle_state_id",
4656
)
@@ -58,6 +68,7 @@ def __init__(self, root: ComponentType) -> None:
5868
async def __aenter__(self) -> Layout:
5969
# create attributes here to avoid access before entering context manager
6070
self._event_handlers: EventHandlerDict = {}
71+
self._render_tasks: set[Task[LayoutUpdateMessage]] = set()
6172

6273
self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue()
6374
root_model_state = _new_root_model_state(self.root, self._rendering_queue.put)
@@ -72,6 +83,7 @@ async def __aenter__(self) -> Layout:
7283
async def __aexit__(self, *exc: Any) -> None:
7384
root_csid = self._root_life_cycle_state_id
7485
root_model_state = self._model_states_by_life_cycle_state_id[root_csid]
86+
await gather(*self._render_tasks, return_exceptions=True)
7587
await self._unmount_model_states([root_model_state])
7688

7789
# delete attributes here to avoid access after exiting context manager
@@ -102,21 +114,35 @@ async def deliver(self, event: LayoutEventMessage) -> None:
102114
async def render(self) -> LayoutUpdateMessage:
103115
"""Await the next available render. This will block until a component is updated"""
104116
while True:
105-
model_state_id = await self._rendering_queue.get()
106-
try:
107-
model_state = self._model_states_by_life_cycle_state_id[model_state_id]
108-
except KeyError:
109-
logger.debug(
110-
"Did not render component with model state ID "
111-
f"{model_state_id!r} - component already unmounted"
112-
)
117+
render_completed = (
118+
create_task(wait(self._render_tasks, return_when=FIRST_COMPLETED))
119+
if self._render_tasks
120+
else get_running_loop().create_future()
121+
)
122+
await wait(
123+
(create_task(self._rendering_queue.ready()), render_completed),
124+
return_when=FIRST_COMPLETED,
125+
)
126+
if render_completed.done():
127+
done, _ = await render_completed
128+
update_task: Task[LayoutUpdateMessage] = done.pop()
129+
self._render_tasks.remove(update_task)
130+
return update_task.result()
113131
else:
114-
update = await self._create_layout_update(model_state)
115-
if REACTPY_CHECK_VDOM_SPEC.current:
116-
root_id = self._root_life_cycle_state_id
117-
root_model = self._model_states_by_life_cycle_state_id[root_id]
118-
validate_vdom_json(root_model.model.current)
119-
return update
132+
model_state_id = await self._rendering_queue.get()
133+
try:
134+
model_state = self._model_states_by_life_cycle_state_id[
135+
model_state_id
136+
]
137+
except KeyError:
138+
logger.debug(
139+
"Did not render component with model state ID "
140+
f"{model_state_id!r} - component already unmounted"
141+
)
142+
else:
143+
self._render_tasks.add(
144+
create_task(self._create_layout_update(model_state))
145+
)
120146

121147
async def _create_layout_update(
122148
self, old_state: _ModelState
@@ -127,6 +153,9 @@ async def _create_layout_update(
127153
async with AsyncExitStack() as exit_stack:
128154
await self._render_component(exit_stack, old_state, new_state, component)
129155

156+
if REACTPY_CHECK_VDOM_SPEC.current:
157+
validate_vdom_json(new_state.model.current)
158+
130159
return {
131160
"type": "layout-update",
132161
"path": new_state.patch_path,
@@ -540,6 +569,7 @@ class _ModelState:
540569
__slots__ = (
541570
"__weakref__",
542571
"_parent_ref",
572+
"_render_semaphore",
543573
"children_by_key",
544574
"index",
545575
"key",
@@ -651,24 +681,27 @@ class _LifeCycleState(NamedTuple):
651681

652682

653683
class _ThreadSafeQueue(Generic[_Type]):
654-
__slots__ = "_loop", "_queue", "_pending"
655-
656684
def __init__(self) -> None:
657-
self._loop = asyncio.get_running_loop()
658-
self._queue: asyncio.Queue[_Type] = asyncio.Queue()
685+
self._loop = get_running_loop()
686+
self._queue: Queue[_Type] = Queue()
659687
self._pending: set[_Type] = set()
688+
self._ready = Event()
660689

661690
def put(self, value: _Type) -> None:
662691
if value not in self._pending:
663692
self._pending.add(value)
664693
self._loop.call_soon_threadsafe(self._queue.put_nowait, value)
694+
self._ready.set()
695+
696+
async def ready(self) -> None:
697+
"""Return when the next value is available"""
698+
await self._ready.wait()
665699

666700
async def get(self) -> _Type:
667-
while True:
668-
value = await self._queue.get()
669-
if value in self._pending:
670-
break
701+
value = await self._queue.get()
671702
self._pending.remove(value)
703+
if not self._pending:
704+
self._ready.clear()
672705
return value
673706

674707

src/py/reactpy/tests/test_client.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def SomeComponent():
4242
incr = await page.wait_for_selector("#incr")
4343

4444
for i in range(3):
45-
assert (await count.get_attribute("data-count")) == str(i)
45+
await poll(count.get_attribute, "data-count").until_equals(str(i))
4646
await incr.click()
4747

4848
# the server is disconnected but the last view state is still shown
@@ -102,7 +102,9 @@ def ButtonWithChangingColor():
102102

103103
for color in ["blue", "red"] * 2:
104104
await button.click()
105-
assert (await _get_style(button))["background-color"] == color
105+
await poll(_get_style, button).until(
106+
lambda style, c=color: style["background-color"] == c
107+
)
106108

107109

108110
async def _get_style(element):

src/py/reactpy/tests/test_core/test_serve.py

+19-11
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
from jsonpointer import set_pointer
66

77
import reactpy
8+
from reactpy.core.hooks import use_effect
89
from reactpy.core.layout import Layout
910
from reactpy.core.serve import serve_layout
1011
from reactpy.core.types import LayoutUpdateMessage
1112
from reactpy.testing import StaticEventHandler
1213
from tests.tooling.common import event_message
14+
from tests.tooling.concurrency import WaitForEvent
1315

1416
EVENT_NAME = "on_event"
1517
STATIC_EVENT_HANDLER = StaticEventHandler()
@@ -96,9 +98,10 @@ async def test_dispatch():
9698

9799

98100
async def test_dispatcher_handles_more_than_one_event_at_a_time():
99-
block_and_never_set = asyncio.Event()
100-
will_block = asyncio.Event()
101-
second_event_did_execute = asyncio.Event()
101+
did_render = WaitForEvent()
102+
block_and_never_set = WaitForEvent()
103+
will_block = WaitForEvent()
104+
second_event_did_execute = WaitForEvent()
102105

103106
blocked_handler = StaticEventHandler()
104107
non_blocked_handler = StaticEventHandler()
@@ -114,6 +117,10 @@ async def block_forever():
114117
async def handle_event():
115118
second_event_did_execute.set()
116119

120+
@use_effect
121+
def set_did_render():
122+
did_render.set()
123+
117124
return reactpy.html.div(
118125
reactpy.html.button({"on_click": block_forever}),
119126
reactpy.html.button({"on_click": handle_event}),
@@ -129,11 +136,12 @@ async def handle_event():
129136
recv_queue.get,
130137
)
131138
)
132-
133-
await recv_queue.put(event_message(blocked_handler.target))
134-
await will_block.wait()
135-
136-
await recv_queue.put(event_message(non_blocked_handler.target))
137-
await second_event_did_execute.wait()
138-
139-
task.cancel()
139+
try:
140+
await did_render.wait()
141+
await recv_queue.put(event_message(blocked_handler.target))
142+
await will_block.wait()
143+
144+
await recv_queue.put(event_message(non_blocked_handler.target))
145+
await second_event_did_execute.wait()
146+
finally:
147+
task.cancel()

0 commit comments

Comments
 (0)