Skip to content

Commit e956e69

Browse files
committed
more test coverage
1 parent d68d7be commit e956e69

File tree

2 files changed

+144
-11
lines changed

2 files changed

+144
-11
lines changed

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

+8-4
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,12 @@ async def stop(self) -> None:
183183
await self._stopped.wait()
184184
return None
185185

186-
if self._started.is_set():
187-
self._cancel_task()
188-
self._stop.set()
186+
self.stop_no_wait()
189187
try:
190188
cleanup = await self.task
191189
except Exception:
192190
logger.exception("Error while stopping effect")
191+
cleanup = None
193192

194193
if cleanup is not None:
195194
try:
@@ -199,13 +198,18 @@ async def stop(self) -> None:
199198

200199
self._stopped.set()
201200

201+
def stop_no_wait(self) -> None:
202+
"""Signal the effect to stop without waiting for it to finish."""
203+
if self._started.is_set():
204+
self._cancel_task()
205+
self._stop.set()
206+
202207
async def started(self) -> None:
203208
"""Wait for the effect to start."""
204209
await self._started.wait()
205210

206211
async def __aenter__(self) -> Self:
207212
self._started.set()
208-
self._cancel_count = self.task.cancelling()
209213
if self._stop.is_set():
210214
self._cancel_task()
211215
return self

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

+136-7
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from reactpy import html
88
from reactpy.config import REACTPY_DEBUG_MODE
99
from reactpy.core._life_cycle_hook import LifeCycleHook
10-
from reactpy.core.hooks import strictly_equal
10+
from reactpy.core.hooks import Effect, strictly_equal
1111
from reactpy.core.layout import Layout
1212
from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll
1313
from reactpy.testing.logs import assert_reactpy_did_not_log
@@ -276,18 +276,18 @@ def double_set_state(event):
276276
first = await display.page.wait_for_selector("#first")
277277
second = await display.page.wait_for_selector("#second")
278278

279-
await poll(first.get_attribute("data-value")).until_equals("0")
280-
await poll(second.get_attribute("data-value")).until_equals("0")
279+
await poll(first.get_attribute, "data-value").until_equals("0")
280+
await poll(second.get_attribute, "data-value").until_equals("0")
281281

282282
await button.click()
283283

284-
await poll(first.get_attribute("data-value")).until_equals("1")
285-
await poll(second.get_attribute("data-value")).until_equals("1")
284+
await poll(first.get_attribute, "data-value").until_equals("1")
285+
await poll(second.get_attribute, "data-value").until_equals("1")
286286

287287
await button.click()
288288

289-
await poll(first.get_attribute("data-value")).until_equals("2")
290-
await poll(second.get_attribute("data-value")).until_equals("2")
289+
await poll(first.get_attribute, "data-value").until_equals("2")
290+
await poll(second.get_attribute, "data-value").until_equals("2")
291291

292292

293293
async def test_use_effect_callback_occurs_after_full_render_is_complete():
@@ -531,13 +531,84 @@ async def effect(e):
531531
async with reactpy.Layout(ComponentWithAsyncEffect()) as layout:
532532
await layout.render()
533533

534+
await effect_ran.wait()
535+
534536
component_hook.latest.schedule_render()
535537

536538
await layout.render()
537539

538540
await asyncio.wait_for(cleanup_ran.wait(), 1)
539541

540542

543+
async def test_effect_with_early_stop_cancels_immediately():
544+
never_happens = asyncio.Event()
545+
did_start = WaitForEvent()
546+
did_cleanup = WaitForEvent()
547+
did_cancel = WaitForEvent()
548+
549+
async def effect_func(e):
550+
async with e:
551+
did_start.set()
552+
try:
553+
await never_happens.wait()
554+
except asyncio.CancelledError:
555+
did_cancel.set()
556+
raise
557+
did_cleanup.set()
558+
559+
effect = Effect(effect_func)
560+
effect.stop_no_wait()
561+
await did_start.wait()
562+
await did_cancel.wait()
563+
await did_cleanup.wait()
564+
565+
566+
async def test_long_effect_is_cancelled():
567+
never_happens = asyncio.Event()
568+
did_start = WaitForEvent()
569+
did_cleanup = WaitForEvent()
570+
did_cancel = WaitForEvent()
571+
572+
async def effect_func(e):
573+
async with e:
574+
did_start.set()
575+
try:
576+
await never_happens.wait()
577+
except asyncio.CancelledError:
578+
did_cancel.set()
579+
raise
580+
did_cleanup.set()
581+
582+
effect = Effect(effect_func)
583+
584+
await did_start.wait()
585+
await effect.stop()
586+
await did_cancel.wait()
587+
await did_cleanup.wait()
588+
589+
590+
async def test_effect_external_cancellation_is_propagated():
591+
did_start = WaitForEvent()
592+
did_cleanup = Ref(False)
593+
594+
async def effect_func(e):
595+
async with e:
596+
did_start.set()
597+
asyncio.current_task().cancel()
598+
await asyncio.sleep(0) # allow cancellation to propagate
599+
did_cleanup.current = True
600+
601+
async def main():
602+
effect = Effect(effect_func)
603+
await did_start.wait()
604+
await effect.stop()
605+
606+
with pytest.raises(asyncio.CancelledError):
607+
await main()
608+
609+
assert not did_cleanup.current
610+
611+
541612
@pytest.mark.skipif(
542613
sys.version_info < (3, 11),
543614
reason="asyncio.Task.uncancel does not exist",
@@ -571,6 +642,64 @@ async def effect(e):
571642
await asyncio.wait_for(cleanup_ran.wait(), 1)
572643

573644

645+
async def test_use_async_effect_error_in_effect_is_propagated_and_handled_gracefully():
646+
component_hook = HookCatcher()
647+
effect_ran = WaitForEvent()
648+
649+
@reactpy.component
650+
@component_hook.capture
651+
def ComponentWithAsyncEffect():
652+
@reactpy.use_effect(dependencies=None) # force this to run every time
653+
async def effect(e):
654+
async with e:
655+
effect_ran.set()
656+
raise ValueError("Something went wrong")
657+
658+
return reactpy.html.div()
659+
660+
with assert_reactpy_did_log(
661+
match_message=r"Error while stopping effect",
662+
error_type=ValueError,
663+
):
664+
async with reactpy.Layout(ComponentWithAsyncEffect()) as layout:
665+
await layout.render()
666+
667+
component_hook.latest.schedule_render()
668+
669+
await layout.render()
670+
671+
672+
async def test_use_async_effect_error_after_stop_is_handled_gracefully():
673+
component_hook = HookCatcher()
674+
effect_ran = WaitForEvent()
675+
cleanup_ran = WaitForEvent()
676+
677+
@reactpy.component
678+
@component_hook.capture
679+
def ComponentWithAsyncEffect():
680+
@reactpy.use_effect(dependencies=None) # force this to run every time
681+
async def effect(e):
682+
async with e:
683+
effect_ran.set()
684+
cleanup_ran.set()
685+
raise ValueError("Something went wrong")
686+
687+
return reactpy.html.div()
688+
689+
with assert_reactpy_did_log(
690+
match_message=r"Error while stopping effect",
691+
error_type=ValueError,
692+
):
693+
async with reactpy.Layout(ComponentWithAsyncEffect()) as layout:
694+
await layout.render()
695+
696+
component_hook.latest.schedule_render()
697+
698+
await layout.render()
699+
700+
await asyncio.wait_for(cleanup_ran.wait(), 1)
701+
702+
574703
async def test_use_async_effect_cleanup_task():
575704
component_hook = HookCatcher()
576705
effect_ran = WaitForEvent()

0 commit comments

Comments
 (0)