Skip to content

Commit 8c82bfb

Browse files
committed
effect function accepts stop event
1 parent 24575fc commit 8c82bfb

File tree

4 files changed

+58
-48
lines changed

4 files changed

+58
-48
lines changed

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

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from __future__ import annotations
22

33
import logging
4-
from asyncio import gather
5-
from collections.abc import Awaitable
6-
from typing import Any, Callable, TypeVar
4+
from asyncio import Event, Task, create_task, gather
5+
from typing import Any, Callable, Protocol, TypeVar
76

87
from anyio import Semaphore
98

@@ -12,6 +11,12 @@
1211

1312
T = TypeVar("T")
1413

14+
15+
class EffectFunc(Protocol):
16+
async def __call__(self, stop: Event) -> None:
17+
...
18+
19+
1520
logger = logging.getLogger(__name__)
1621

1722
_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
@@ -95,8 +100,9 @@ async def stop_effect():
95100
"__weakref__",
96101
"_context_providers",
97102
"_current_state_index",
98-
"_effect_cleanups",
99-
"_effect_startups",
103+
"_effect_funcs",
104+
"_effect_tasks",
105+
"_effect_stops",
100106
"_render_access",
101107
"_rendered_atleast_once",
102108
"_schedule_render_callback",
@@ -117,8 +123,9 @@ def __init__(
117123
self._rendered_atleast_once = False
118124
self._current_state_index = 0
119125
self._state: tuple[Any, ...] = ()
120-
self._effect_startups: list[Callable[[], Awaitable[None]]] = []
121-
self._effect_cleanups: list[Callable[[], Awaitable[None]]] = []
126+
self._effect_funcs: list[EffectFunc] = []
127+
self._effect_tasks: list[Task[None]] = []
128+
self._effect_stops: list[Event] = []
122129
self._render_access = Semaphore(1) # ensure only one render at a time
123130

124131
def schedule_render(self) -> None:
@@ -138,19 +145,15 @@ def use_state(self, function: Callable[[], T]) -> T:
138145
self._current_state_index += 1
139146
return result
140147

141-
def add_effect(
142-
self,
143-
start_effect: Callable[[], Awaitable[None]],
144-
clean_effect: Callable[[], Awaitable[None]],
145-
) -> None:
148+
def add_effect(self, effect_func: EffectFunc) -> None:
146149
"""Add an effect to this hook
147150
148-
Effects are started when the component is done renderig and cleaned up when the
149-
component is removed from the layout. Any other actions (e.g. re-running the
150-
effect if a dependency changes) are the responsibility of the effect itself.
151+
A task to run the effect is created when the component is done rendering.
152+
When the component will be unmounted, the event passed to the effect is
153+
triggered and the task is awaited. The effect should eventually halt after
154+
the event is triggered.
151155
"""
152-
self._effect_startups.append(start_effect)
153-
self._effect_cleanups.append(clean_effect)
156+
self._effect_funcs.append(effect_func)
154157

155158
def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
156159
self._context_providers[provider.type] = provider
@@ -176,24 +179,25 @@ async def affect_component_did_render(self) -> None:
176179

177180
async def affect_layout_did_render(self) -> None:
178181
"""The layout completed a render"""
179-
try:
180-
await gather(*[start() for start in self._effect_startups])
181-
except Exception:
182-
logger.exception("Error during effect startup")
183-
finally:
184-
self._effect_startups.clear()
185-
if self._schedule_render_later:
186-
self._schedule_render()
187-
self._schedule_render_later = False
182+
stop = Event()
183+
self._effect_stops.append(stop)
184+
self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs)
185+
self._effect_funcs.clear()
186+
if self._schedule_render_later:
187+
self._schedule_render()
188+
self._schedule_render_later = False
188189

189190
async def affect_component_will_unmount(self) -> None:
190191
"""The component is about to be removed from the layout"""
192+
for stop in self._effect_stops:
193+
stop.set()
194+
self._effect_stops.clear()
191195
try:
192-
await gather(*[clean() for clean in self._effect_cleanups])
196+
await gather(*self._effect_tasks)
193197
except Exception:
194-
logger.exception("Error during effect cleanup")
198+
logger.exception("Error in effect")
195199
finally:
196-
self._effect_cleanups.clear()
200+
self._effect_tasks.clear()
197201

198202
def set_current(self) -> None:
199203
"""Set this hook as the active hook in this thread

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,24 +152,26 @@ def sync_function() -> _EffectCleanFunc | None:
152152

153153
def clean_future() -> None:
154154
if not task.cancel():
155-
clean = task.result()
156-
if clean is not None:
157-
clean()
155+
try:
156+
clean = task.result()
157+
except asyncio.CancelledError:
158+
pass
159+
else:
160+
if clean is not None:
161+
clean()
158162

159163
return clean_future
160164

161-
async def start_effect() -> None:
165+
async def effect(stop: asyncio.Event) -> None:
162166
if last_clean_callback.current is not None:
163167
last_clean_callback.current()
164168
last_clean_callback.current = None
165-
last_clean_callback.current = sync_function()
169+
clean = last_clean_callback.current = sync_function()
170+
await stop.wait()
171+
if clean is not None:
172+
clean()
166173

167-
async def clean_effect() -> None:
168-
if last_clean_callback.current is not None:
169-
last_clean_callback.current()
170-
last_clean_callback.current = None
171-
172-
return memoize(lambda: hook.add_effect(start_effect, clean_effect))
174+
return memoize(lambda: hook.add_effect(effect))
173175

174176
if function is not None:
175177
add_effect(function)

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,14 @@ async def _concurrent_render(self) -> LayoutUpdateMessage:
143143
if self._render_tasks
144144
else get_running_loop().create_future()
145145
)
146-
await wait(
147-
(create_task(self._rendering_queue.ready()), render_completed),
148-
return_when=FIRST_COMPLETED,
149-
)
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+
150154
if render_completed.done():
151155
done, _ = await render_completed
152156
update_task: Task[LayoutUpdateMessage] = done.pop()

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ def bad_effect():
558558

559559
return reactpy.html.div()
560560

561-
with assert_reactpy_did_log(match_message=r"Error during effect startup"):
561+
with assert_reactpy_did_log(match_message=r"Error in effect"):
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"Error during effect cleanup",
587+
match_message=r"Error in effect",
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"Error during effect startup",
1006+
match_message=r"Error in effect",
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="Error during effect cleanup",
1249+
match_message="Error in effect",
12501250
error_type=ValueError,
12511251
match_error="The error message",
12521252
):

0 commit comments

Comments
 (0)