Skip to content

Commit 80d3b7a

Browse files
committed
simplify concurrent render process
1 parent 8c82bfb commit 80d3b7a

File tree

4 files changed

+111
-69
lines changed

4 files changed

+111
-69
lines changed

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

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,12 @@ async def stop_effect():
101101
"_context_providers",
102102
"_current_state_index",
103103
"_effect_funcs",
104-
"_effect_tasks",
105104
"_effect_stops",
105+
"_effect_tasks",
106106
"_render_access",
107107
"_rendered_atleast_once",
108108
"_schedule_render_callback",
109-
"_schedule_render_later",
109+
"_scheduled_render",
110110
"_state",
111111
"component",
112112
)
@@ -119,7 +119,7 @@ def __init__(
119119
) -> None:
120120
self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
121121
self._schedule_render_callback = schedule_render
122-
self._schedule_render_later = False
122+
self._scheduled_render = False
123123
self._rendered_atleast_once = False
124124
self._current_state_index = 0
125125
self._state: tuple[Any, ...] = ()
@@ -129,10 +129,15 @@ def __init__(
129129
self._render_access = Semaphore(1) # ensure only one render at a time
130130

131131
def schedule_render(self) -> None:
132-
if self._is_rendering():
133-
self._schedule_render_later = True
132+
if self._scheduled_render:
133+
return None
134+
try:
135+
self._schedule_render_callback()
136+
except Exception:
137+
msg = f"Failed to schedule render via {self._schedule_render_callback}"
138+
logger.exception(msg)
134139
else:
135-
self._schedule_render()
140+
self._scheduled_render = True
136141

137142
def use_state(self, function: Callable[[], T]) -> T:
138143
if not self._rendered_atleast_once:
@@ -166,6 +171,7 @@ def get_context_provider(
166171
async def affect_component_will_render(self, component: ComponentType) -> None:
167172
"""The component is about to render"""
168173
await self._render_access.acquire()
174+
self._scheduled_render = False
169175
self.component = component
170176
self.set_current()
171177

@@ -183,9 +189,6 @@ async def affect_layout_did_render(self) -> None:
183189
self._effect_stops.append(stop)
184190
self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs)
185191
self._effect_funcs.clear()
186-
if self._schedule_render_later:
187-
self._schedule_render()
188-
self._schedule_render_later = False
189192

190193
async def affect_component_will_unmount(self) -> None:
191194
"""The component is about to be removed from the layout"""
@@ -215,14 +218,3 @@ def unset_current(self) -> None:
215218
"""Unset this hook as the active hook in this thread"""
216219
if _HOOK_STATE.get().pop() is not self:
217220
raise RuntimeError("Hook stack is in an invalid state") # nocov
218-
219-
def _is_rendering(self) -> bool:
220-
return self._render_access.value == 0
221-
222-
def _schedule_render(self) -> None:
223-
try:
224-
self._schedule_render_callback()
225-
except Exception:
226-
logger.exception(
227-
f"Failed to schedule render via {self._schedule_render_callback}"
228-
)

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

Lines changed: 39 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
import abc
44
from asyncio import (
55
FIRST_COMPLETED,
6+
CancelledError,
67
Event,
78
Queue,
89
Task,
910
create_task,
10-
gather,
1111
get_running_loop,
1212
wait,
1313
)
@@ -27,6 +27,8 @@
2727
from uuid import uuid4
2828
from weakref import ref as weakref
2929

30+
from anyio import Semaphore
31+
3032
from reactpy.config import (
3133
REACTPY_CHECK_VDOM_SPEC,
3234
REACTPY_DEBUG_MODE,
@@ -55,6 +57,7 @@ class Layout:
5557
"_event_handlers",
5658
"_rendering_queue",
5759
"_render_tasks",
60+
"_render_tasks_ready",
5861
"_root_life_cycle_state_id",
5962
"_model_states_by_life_cycle_state_id",
6063
)
@@ -73,21 +76,28 @@ async def __aenter__(self) -> Layout:
7376
# create attributes here to avoid access before entering context manager
7477
self._event_handlers: EventHandlerDict = {}
7578
self._render_tasks: set[Task[LayoutUpdateMessage]] = set()
79+
self._render_tasks_ready: Semaphore = Semaphore(0)
7680

7781
self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue()
78-
root_model_state = _new_root_model_state(self.root, self._rendering_queue.put)
82+
root_model_state = _new_root_model_state(self.root, self._schedule_render_task)
7983

8084
self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id
81-
self._rendering_queue.put(root_id)
82-
8385
self._model_states_by_life_cycle_state_id = {root_id: root_model_state}
86+
self._schedule_render_task(root_id)
8487

8588
return self
8689

8790
async def __aexit__(self, *exc: Any) -> None:
8891
root_csid = self._root_life_cycle_state_id
8992
root_model_state = self._model_states_by_life_cycle_state_id[root_csid]
90-
await gather(*self._render_tasks, return_exceptions=True)
93+
94+
for t in self._render_tasks:
95+
t.cancel()
96+
try:
97+
await t
98+
except CancelledError:
99+
pass
100+
91101
await self._unmount_model_states([root_model_state])
92102

93103
# delete attributes here to avoid access after exiting context manager
@@ -137,40 +147,11 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov
137147

138148
async def _concurrent_render(self) -> LayoutUpdateMessage:
139149
"""Await the next available render. This will block until a component is updated"""
140-
while True:
141-
render_completed = (
142-
create_task(wait(self._render_tasks, return_when=FIRST_COMPLETED))
143-
if self._render_tasks
144-
else get_running_loop().create_future()
145-
)
146-
queue_ready = create_task(self._rendering_queue.ready())
147-
try:
148-
await wait((queue_ready, render_completed), return_when=FIRST_COMPLETED)
149-
finally:
150-
# Ensure we delete this task to avoid warnings that
151-
# task was deleted without being awaited.
152-
queue_ready.cancel()
153-
154-
if render_completed.done():
155-
done, _ = await render_completed
156-
update_task: Task[LayoutUpdateMessage] = done.pop()
157-
self._render_tasks.remove(update_task)
158-
return update_task.result()
159-
else:
160-
model_state_id = await self._rendering_queue.get()
161-
try:
162-
model_state = self._model_states_by_life_cycle_state_id[
163-
model_state_id
164-
]
165-
except KeyError:
166-
logger.debug(
167-
"Did not render component with model state ID "
168-
f"{model_state_id!r} - component already unmounted"
169-
)
170-
else:
171-
self._render_tasks.add(
172-
create_task(self._create_layout_update(model_state))
173-
)
150+
await self._render_tasks_ready.acquire()
151+
done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED)
152+
update_task: Task[LayoutUpdateMessage] = done.pop()
153+
self._render_tasks.remove(update_task)
154+
return update_task.result()
174155

175156
async def _create_layout_update(
176157
self, old_state: _ModelState
@@ -403,7 +384,7 @@ async def _render_model_children(
403384
index,
404385
key,
405386
child,
406-
self._rendering_queue.put,
387+
self._schedule_render_task,
407388
)
408389
elif old_child_state.is_component_state and (
409390
old_child_state.life_cycle_state.component.type != child.type
@@ -415,15 +396,15 @@ async def _render_model_children(
415396
index,
416397
key,
417398
child,
418-
self._rendering_queue.put,
399+
self._schedule_render_task,
419400
)
420401
else:
421402
new_child_state = _update_component_model_state(
422403
old_child_state,
423404
new_state,
424405
index,
425406
child,
426-
self._rendering_queue.put,
407+
self._schedule_render_task,
427408
)
428409
await self._render_component(
429410
exit_stack, old_child_state, new_child_state, child
@@ -458,7 +439,7 @@ async def _render_model_children_without_old_state(
458439
new_state.children_by_key[key] = child_state
459440
elif child_type is _COMPONENT_TYPE:
460441
child_state = _make_component_model_state(
461-
new_state, index, key, child, self._rendering_queue.put
442+
new_state, index, key, child, self._schedule_render_task
462443
)
463444
await self._render_component(exit_stack, None, child_state, child)
464445
else:
@@ -479,6 +460,21 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
479460

480461
to_unmount.extend(model_state.children_by_key.values())
481462

463+
def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None:
464+
if not REACTPY_FEATURE_CONCURRENT_RENDERING.current:
465+
self._rendering_queue.put(lcs_id)
466+
return None
467+
try:
468+
model_state = self._model_states_by_life_cycle_state_id[lcs_id]
469+
except KeyError:
470+
logger.debug(
471+
"Did not render component with model state ID "
472+
f"{lcs_id!r} - component already unmounted"
473+
)
474+
else:
475+
self._render_tasks.add(create_task(self._create_layout_update(model_state)))
476+
self._render_tasks_ready.release()
477+
482478
def __repr__(self) -> str:
483479
return f"{type(self).__name__}({self.root})"
484480

src/py/reactpy/tests/test_core/test_hooks.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@ def SimpleComponentWithHook():
2828

2929

3030
async def test_simple_stateful_component():
31+
index = 0
32+
33+
def set_index(x):
34+
return None
35+
3136
@reactpy.component
3237
def SimpleStatefulComponent():
38+
nonlocal index, set_index
3339
index, set_index = reactpy.hooks.use_state(0)
34-
set_index(index + 1)
3540
return reactpy.html.div(index)
3641

3742
sse = SimpleStatefulComponent()
@@ -45,6 +50,7 @@ def SimpleStatefulComponent():
4550
"children": [{"tagName": "div", "children": ["0"]}],
4651
},
4752
)
53+
set_index(index + 1)
4854

4955
update_2 = await layout.render()
5056
assert update_2 == update_message(
@@ -54,6 +60,7 @@ def SimpleStatefulComponent():
5460
"children": [{"tagName": "div", "children": ["1"]}],
5561
},
5662
)
63+
set_index(index + 1)
5764

5865
update_3 = await layout.render()
5966
assert update_3 == update_message(
@@ -1026,13 +1033,13 @@ def SetStateDuringRender():
10261033

10271034
async with Layout(SetStateDuringRender()) as layout:
10281035
await layout.render()
1029-
assert render_count.current == 1
1030-
await layout.render()
1031-
assert render_count.current == 2
10321036

1033-
# there should be no more renders to perform
1037+
# we expect a second render to be triggered in the background
1038+
await poll(lambda: render_count.current).until_equals(2)
1039+
1040+
# there should be no more renders that happen
10341041
with pytest.raises(asyncio.TimeoutError):
1035-
await asyncio.wait_for(layout.render(), timeout=0.1)
1042+
await poll(lambda: render_count.current).until_equals(3, timeout=0.1)
10361043

10371044

10381045
@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode")

src/py/reactpy/tests/test_core/test_layout.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from reactpy.utils import Ref
2424
from tests.tooling import select
25+
from tests.tooling.aio import Event
2526
from tests.tooling.common import event_message, update_message
2627
from tests.tooling.hooks import use_force_render, use_toggle
2728
from tests.tooling.layout import layout_runner
@@ -1250,3 +1251,49 @@ def App():
12501251
c, c_info = find_element(tree, select.id_equals("C"))
12511252
assert c_info.path == (0, 1, 0)
12521253
assert c["attributes"]["color"] == "blue"
1254+
1255+
1256+
async def test_concurrent_renders():
1257+
child_1_hook = HookCatcher()
1258+
child_2_hook = HookCatcher()
1259+
child_1_rendered = Event()
1260+
child_2_rendered = Event()
1261+
child_1_render_count = Ref(0)
1262+
child_2_render_count = Ref(0)
1263+
1264+
@component
1265+
def outer():
1266+
return html._(child_1(), child_2())
1267+
1268+
@component
1269+
@child_1_hook.capture
1270+
def child_1():
1271+
child_1_rendered.set()
1272+
child_1_render_count.current += 1
1273+
1274+
@component
1275+
@child_2_hook.capture
1276+
def child_2():
1277+
child_2_rendered.set()
1278+
child_2_render_count.current += 1
1279+
1280+
async with Layout(outer()) as layout:
1281+
await layout.render()
1282+
1283+
# clear render events and counts
1284+
child_1_rendered.clear()
1285+
child_2_rendered.clear()
1286+
child_1_render_count.current = 0
1287+
child_2_render_count.current = 0
1288+
1289+
# we schedule two renders but expect only one
1290+
child_1_hook.latest.schedule_render()
1291+
child_1_hook.latest.schedule_render()
1292+
child_2_hook.latest.schedule_render()
1293+
child_2_hook.latest.schedule_render()
1294+
1295+
await child_1_rendered.wait()
1296+
await child_2_rendered.wait()
1297+
1298+
assert child_1_render_count.current == 1
1299+
assert child_2_render_count.current == 1

0 commit comments

Comments
 (0)