Skip to content

Commit a6dd141

Browse files
committed
concurrent renders
1 parent c5925ec commit a6dd141

File tree

7 files changed

+84
-47
lines changed

7 files changed

+84
-47
lines changed

Diff for: src/py/reactpy/reactpy/core/_life_cycle_hook.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ class LifeCycleHook:
8888
"__weakref__",
8989
"_context_providers",
9090
"_current_state_index",
91-
"_effect_generators",
91+
"_pending_effects",
9292
"_render_access",
9393
"_rendered_atleast_once",
94+
"_running_effects",
9495
"_schedule_render_callback",
9596
"_schedule_render_later",
9697
"_state",
@@ -109,7 +110,8 @@ def __init__(
109110
self._rendered_atleast_once = False
110111
self._current_state_index = 0
111112
self._state: tuple[Any, ...] = ()
112-
self._effect_generators: list[AsyncGenerator[None, None]] = []
113+
self._pending_effects: list[AsyncGenerator[None, None]] = []
114+
self._running_effects: list[AsyncGenerator[None, None]] = []
113115
self._render_access = Semaphore(1) # ensure only one render at a time
114116

115117
def schedule_render(self) -> None:
@@ -131,7 +133,7 @@ def use_state(self, function: Callable[[], T]) -> T:
131133

132134
def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None:
133135
"""Add an effect to this hook"""
134-
self._effect_generators.append(effect_func())
136+
self._pending_effects.append(effect_func())
135137

136138
def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
137139
self._context_providers[provider.type] = provider
@@ -150,29 +152,32 @@ async def affect_component_will_render(self, component: ComponentType) -> None:
150152
async def affect_component_did_render(self) -> None:
151153
"""The component completed a render"""
152154
self.unset_current()
153-
del self.component
154155
self._rendered_atleast_once = True
155156
self._current_state_index = 0
156157
self._render_access.release()
157158

158159
async def affect_layout_did_render(self) -> None:
159160
"""The layout completed a render"""
160161
try:
161-
await gather(*[g.asend(None) for g in self._effect_generators])
162+
await gather(*[g.asend(None) for g in self._pending_effects])
163+
self._running_effects.extend(self._pending_effects)
162164
except Exception:
163-
logger.exception("Error during effect execution")
165+
logger.exception("Error during effect startup")
166+
finally:
167+
self._pending_effects.clear()
164168
if self._schedule_render_later:
165169
self._schedule_render()
166170
self._schedule_render_later = False
171+
del self.component
167172

168173
async def affect_component_will_unmount(self) -> None:
169174
"""The component is about to be removed from the layout"""
170175
try:
171-
await gather(*[g.aclose() for g in self._effect_generators])
176+
await gather(*[g.aclose() for g in self._running_effects])
172177
except Exception:
173-
logger.exception("Error during effect cancellation")
178+
logger.exception("Error during effect cleanup")
174179
finally:
175-
self._effect_generators.clear()
180+
self._running_effects.clear()
176181

177182
def set_current(self) -> None:
178183
"""Set this hook as the active hook in this thread
@@ -192,7 +197,7 @@ def unset_current(self) -> None:
192197
raise RuntimeError("Hook stack is in an invalid state") # nocov
193198

194199
def _is_rendering(self) -> bool:
195-
return self._render_access.value != 0
200+
return self._render_access.value == 0
196201

197202
def _schedule_render(self) -> None:
198203
try:

Diff for: src/py/reactpy/reactpy/core/hooks.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,20 @@ async def effect() -> AsyncGenerator[None, None]:
160160
if last_clean_callback.current is not None:
161161
last_clean_callback.current()
162162

163-
clean = last_clean_callback.current = sync_function()
163+
cleaned = False
164+
clean = sync_function()
165+
166+
def callback() -> None:
167+
nonlocal cleaned
168+
if clean and not cleaned:
169+
cleaned = True
170+
clean()
171+
172+
last_clean_callback.current = callback
164173
try:
165174
yield
166175
finally:
167-
if clean is not None:
168-
clean()
176+
callback()
169177

170178
return memoize(lambda: hook.add_effect(effect))
171179

@@ -266,7 +274,7 @@ def render(self) -> VdomDict:
266274
return {"tagName": "", "children": self.children}
267275

268276
def __repr__(self) -> str:
269-
return f"{type(self).__name__}({self.type})"
277+
return f"ContextProvider({self.type})"
270278

271279

272280
_ActionType = TypeVar("_ActionType")

Diff for: src/py/reactpy/tests/test_client.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ def SomeComponent():
3030
),
3131
)
3232

33+
async def get_count():
34+
# need to refetch element because may unmount on reconnect
35+
count = await page.wait_for_selector("#count")
36+
return await count.get_attribute("data-count")
37+
3338
async with AsyncExitStack() as exit_stack:
3439
server = await exit_stack.enter_async_context(BackendFixture(port=port))
3540
display = await exit_stack.enter_async_context(
@@ -38,11 +43,10 @@ def SomeComponent():
3843

3944
await display.show(SomeComponent)
4045

41-
count = await page.wait_for_selector("#count")
4246
incr = await page.wait_for_selector("#incr")
4347

4448
for i in range(3):
45-
assert (await count.get_attribute("data-count")) == str(i)
49+
await poll(get_count).until_equals(str(i))
4650
await incr.click()
4751

4852
# the server is disconnected but the last view state is still shown
@@ -57,13 +61,7 @@ def SomeComponent():
5761
# use mount instead of show to avoid a page refresh
5862
display.backend.mount(SomeComponent)
5963

60-
async def get_count():
61-
# need to refetch element because may unmount on reconnect
62-
count = await page.wait_for_selector("#count")
63-
return await count.get_attribute("data-count")
64-
6564
for i in range(3):
66-
# it may take a moment for the websocket to reconnect so need to poll
6765
await poll(get_count).until_equals(str(i))
6866

6967
# need to refetch element because may unmount on reconnect
@@ -98,11 +96,15 @@ def ButtonWithChangingColor():
9896

9997
button = await display.page.wait_for_selector("#my-button")
10098

101-
assert (await _get_style(button))["background-color"] == "red"
99+
await poll(_get_style, button).until(
100+
lambda style: style["background-color"] == "red"
101+
)
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):

Diff for: src/py/reactpy/tests/test_core/test_hooks.py

+10-10
Original file line numberDiff line numberDiff line change
@@ -274,18 +274,18 @@ def double_set_state(event):
274274
first = await display.page.wait_for_selector("#first")
275275
second = await display.page.wait_for_selector("#second")
276276

277-
assert (await first.get_attribute("data-value")) == "0"
278-
assert (await second.get_attribute("data-value")) == "0"
277+
await poll(first.get_attribute, "data-value").until_equals("0")
278+
await poll(second.get_attribute, "data-value").until_equals("0")
279279

280280
await button.click()
281281

282-
assert (await first.get_attribute("data-value")) == "1"
283-
assert (await second.get_attribute("data-value")) == "1"
282+
await poll(first.get_attribute, "data-value").until_equals("1")
283+
await poll(second.get_attribute, "data-value").until_equals("1")
284284

285285
await button.click()
286286

287-
assert (await first.get_attribute("data-value")) == "2"
288-
assert (await second.get_attribute("data-value")) == "2"
287+
await poll(first.get_attribute, "data-value").until_equals("2")
288+
await poll(second.get_attribute, "data-value").until_equals("2")
289289

290290

291291
async def test_use_effect_callback_occurs_after_full_render_is_complete():
@@ -558,7 +558,7 @@ def bad_effect():
558558

559559
return reactpy.html.div()
560560

561-
with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"):
561+
with assert_reactpy_did_log(match_message=r"Error during effect startup"):
562562
async with reactpy.Layout(ComponentWithEffect()) as layout:
563563
await layout.render() # no error
564564

@@ -584,7 +584,7 @@ def bad_cleanup():
584584
return reactpy.html.div()
585585

586586
with assert_reactpy_did_log(
587-
match_message=r"Pre-unmount effect .*? failed",
587+
match_message=r"Error during effect cleanup",
588588
error_type=ValueError,
589589
):
590590
async with reactpy.Layout(OuterComponent()) as layout:
@@ -1003,7 +1003,7 @@ def bad_effect():
10031003
return reactpy.html.div()
10041004

10051005
with assert_reactpy_did_log(
1006-
match_message=r"post-render effect .*? failed",
1006+
match_message=r"Error during effect startup",
10071007
error_type=ValueError,
10081008
match_error="The error message",
10091009
):
@@ -1246,7 +1246,7 @@ def bad_cleanup():
12461246
return reactpy.html.div()
12471247

12481248
with assert_reactpy_did_log(
1249-
match_message="Component post-render effect .*? failed",
1249+
match_message="Error during effect cleanup",
12501250
error_type=ValueError,
12511251
match_error="The error message",
12521252
):

Diff for: src/py/reactpy/tests/test_core/test_layout.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def make_child_model(state):
164164
async def test_layout_render_error_has_partial_update_with_error_message():
165165
@reactpy.component
166166
def Main():
167-
return reactpy.html.div([OkChild(), BadChild(), OkChild()])
167+
return reactpy.html.div(OkChild(), BadChild(), OkChild())
168168

169169
@reactpy.component
170170
def OkChild():
@@ -622,7 +622,7 @@ async def test_hooks_for_keyed_components_get_garbage_collected():
622622
def Outer():
623623
items, set_items = reactpy.hooks.use_state([1, 2, 3])
624624
pop_item.current = lambda: set_items(items[:-1])
625-
return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items)
625+
return reactpy.html.div([Inner(key=k, finalizer_id=k) for k in items])
626626

627627
@reactpy.component
628628
def Inner(finalizer_id):

Diff for: src/py/reactpy/tests/test_core/test_serve.py

+19-11
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
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
13+
from tests.tooling.aio import Event
1214
from tests.tooling.common import event_message
1315

1416
EVENT_NAME = "on_event"
@@ -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 = Event()
102+
block_and_never_set = Event()
103+
will_block = Event()
104+
second_event_did_execute = Event()
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()

Diff for: src/py/reactpy/tests/tooling/aio.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from asyncio import Event as _Event
2+
from asyncio import wait_for
3+
4+
from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
5+
6+
7+
class Event(_Event):
8+
"""An event with a ``wait_for`` method."""
9+
10+
async def wait(self, timeout: float | None = None):
11+
return await wait_for(
12+
super().wait(),
13+
timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current,
14+
)

0 commit comments

Comments
 (0)