Skip to content

Commit 86b749e

Browse files
committed
Quick attempt at some fixing async dependencies
1 parent 9ee027c commit 86b749e

File tree

2 files changed

+84
-6
lines changed

2 files changed

+84
-6
lines changed

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

tests/test_core/test_hooks.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,41 @@ async def effect():
600600
event_that_never_occurs.set()
601601

602602

603+
async def test_async_effect_sleep_is_cancelled_on_re_render():
604+
"""Test that async effects waiting on asyncio.sleep are properly cancelled."""
605+
component_hook = HookCatcher()
606+
effect_ran = asyncio.Event()
607+
effect_was_cancelled = asyncio.Event()
608+
609+
@reactpy.component
610+
@component_hook.capture
611+
def ComponentWithSleepEffect():
612+
@reactpy.hooks.use_async_effect(dependencies=None)
613+
async def effect():
614+
effect_ran.set()
615+
try:
616+
await asyncio.sleep(1000)
617+
except asyncio.CancelledError:
618+
effect_was_cancelled.set()
619+
raise
620+
621+
return reactpy.html.div()
622+
623+
async with Layout(ComponentWithSleepEffect()) as layout:
624+
await layout.render()
625+
626+
# Wait for the effect to start
627+
await effect_ran.wait()
628+
629+
# Trigger a re-render which should cancel the previous effect
630+
component_hook.latest.schedule_render()
631+
await layout.render()
632+
633+
# Verify the previous effect was cancelled
634+
await asyncio.wait_for(effect_was_cancelled.wait(), 1)
635+
636+
637+
603638
async def test_error_in_effect_is_gracefully_handled():
604639
@reactpy.component
605640
def ComponentWithEffect():
@@ -1349,3 +1384,40 @@ async def run_test():
13491384
await layout.render()
13501385

13511386
asyncio.run(run_test())
1387+
1388+
1389+
async def test_async_effect_cancelled_on_dependency_change():
1390+
"""Test that async effects are cancelled when dependencies change."""
1391+
set_state = reactpy.Ref()
1392+
effect_ran = asyncio.Event()
1393+
effect_was_cancelled = asyncio.Event()
1394+
1395+
@reactpy.component
1396+
def ComponentWithDependentEffect():
1397+
state, set_state.current = reactpy.hooks.use_state(0)
1398+
1399+
@reactpy.hooks.use_async_effect(dependencies=[state])
1400+
async def effect():
1401+
effect_ran.set()
1402+
try:
1403+
await asyncio.sleep(1000)
1404+
except asyncio.CancelledError:
1405+
effect_was_cancelled.set()
1406+
raise
1407+
1408+
return reactpy.html.div()
1409+
1410+
async with Layout(ComponentWithDependentEffect()) as layout:
1411+
await layout.render()
1412+
1413+
# Wait for the effect to start
1414+
await effect_ran.wait()
1415+
effect_ran.clear()
1416+
1417+
# Change state to trigger effect cleanup/re-run
1418+
set_state.current(1)
1419+
await layout.render()
1420+
1421+
# Verify the previous effect was cancelled
1422+
await asyncio.wait_for(effect_was_cancelled.wait(), 1)
1423+

0 commit comments

Comments
 (0)