From e956e699b162277358a4a156d8394e3c36fceb35 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 13:04:10 -0700 Subject: [PATCH] more test coverage --- src/py/reactpy/reactpy/core/hooks.py | 12 +- src/py/reactpy/tests/test_core/test_hooks.py | 143 ++++++++++++++++++- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index b57fd457b..03dcf41a6 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -183,13 +183,12 @@ async def stop(self) -> None: await self._stopped.wait() return None - if self._started.is_set(): - self._cancel_task() - self._stop.set() + self.stop_no_wait() try: cleanup = await self.task except Exception: logger.exception("Error while stopping effect") + cleanup = None if cleanup is not None: try: @@ -199,13 +198,18 @@ async def stop(self) -> None: self._stopped.set() + def stop_no_wait(self) -> None: + """Signal the effect to stop without waiting for it to finish.""" + if self._started.is_set(): + self._cancel_task() + self._stop.set() + async def started(self) -> None: """Wait for the effect to start.""" await self._started.wait() async def __aenter__(self) -> Self: self._started.set() - self._cancel_count = self.task.cancelling() if self._stop.is_set(): self._cancel_task() return self diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 5bdee5bac..495a6a687 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -7,7 +7,7 @@ from reactpy import html from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.hooks import strictly_equal +from reactpy.core.hooks import Effect, strictly_equal from reactpy.core.layout import Layout from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll from reactpy.testing.logs import assert_reactpy_did_not_log @@ -276,18 +276,18 @@ def double_set_state(event): first = await display.page.wait_for_selector("#first") second = await display.page.wait_for_selector("#second") - await poll(first.get_attribute("data-value")).until_equals("0") - await poll(second.get_attribute("data-value")).until_equals("0") + await poll(first.get_attribute, "data-value").until_equals("0") + await poll(second.get_attribute, "data-value").until_equals("0") await button.click() - await poll(first.get_attribute("data-value")).until_equals("1") - await poll(second.get_attribute("data-value")).until_equals("1") + await poll(first.get_attribute, "data-value").until_equals("1") + await poll(second.get_attribute, "data-value").until_equals("1") await button.click() - await poll(first.get_attribute("data-value")).until_equals("2") - await poll(second.get_attribute("data-value")).until_equals("2") + await poll(first.get_attribute, "data-value").until_equals("2") + await poll(second.get_attribute, "data-value").until_equals("2") async def test_use_effect_callback_occurs_after_full_render_is_complete(): @@ -531,6 +531,8 @@ async def effect(e): async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() + await effect_ran.wait() + component_hook.latest.schedule_render() await layout.render() @@ -538,6 +540,75 @@ async def effect(e): await asyncio.wait_for(cleanup_ran.wait(), 1) +async def test_effect_with_early_stop_cancels_immediately(): + never_happens = asyncio.Event() + did_start = WaitForEvent() + did_cleanup = WaitForEvent() + did_cancel = WaitForEvent() + + async def effect_func(e): + async with e: + did_start.set() + try: + await never_happens.wait() + except asyncio.CancelledError: + did_cancel.set() + raise + did_cleanup.set() + + effect = Effect(effect_func) + effect.stop_no_wait() + await did_start.wait() + await did_cancel.wait() + await did_cleanup.wait() + + +async def test_long_effect_is_cancelled(): + never_happens = asyncio.Event() + did_start = WaitForEvent() + did_cleanup = WaitForEvent() + did_cancel = WaitForEvent() + + async def effect_func(e): + async with e: + did_start.set() + try: + await never_happens.wait() + except asyncio.CancelledError: + did_cancel.set() + raise + did_cleanup.set() + + effect = Effect(effect_func) + + await did_start.wait() + await effect.stop() + await did_cancel.wait() + await did_cleanup.wait() + + +async def test_effect_external_cancellation_is_propagated(): + did_start = WaitForEvent() + did_cleanup = Ref(False) + + async def effect_func(e): + async with e: + did_start.set() + asyncio.current_task().cancel() + await asyncio.sleep(0) # allow cancellation to propagate + did_cleanup.current = True + + async def main(): + effect = Effect(effect_func) + await did_start.wait() + await effect.stop() + + with pytest.raises(asyncio.CancelledError): + await main() + + assert not did_cleanup.current + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="asyncio.Task.uncancel does not exist", @@ -571,6 +642,64 @@ async def effect(e): await asyncio.wait_for(cleanup_ran.wait(), 1) +async def test_use_async_effect_error_in_effect_is_propagated_and_handled_gracefully(): + component_hook = HookCatcher() + effect_ran = WaitForEvent() + + @reactpy.component + @component_hook.capture + def ComponentWithAsyncEffect(): + @reactpy.use_effect(dependencies=None) # force this to run every time + async def effect(e): + async with e: + effect_ran.set() + raise ValueError("Something went wrong") + + return reactpy.html.div() + + with assert_reactpy_did_log( + match_message=r"Error while stopping effect", + error_type=ValueError, + ): + async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: + await layout.render() + + component_hook.latest.schedule_render() + + await layout.render() + + +async def test_use_async_effect_error_after_stop_is_handled_gracefully(): + component_hook = HookCatcher() + effect_ran = WaitForEvent() + cleanup_ran = WaitForEvent() + + @reactpy.component + @component_hook.capture + def ComponentWithAsyncEffect(): + @reactpy.use_effect(dependencies=None) # force this to run every time + async def effect(e): + async with e: + effect_ran.set() + cleanup_ran.set() + raise ValueError("Something went wrong") + + return reactpy.html.div() + + with assert_reactpy_did_log( + match_message=r"Error while stopping effect", + error_type=ValueError, + ): + async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: + await layout.render() + + component_hook.latest.schedule_render() + + await layout.render() + + await asyncio.wait_for(cleanup_ran.wait(), 1) + + async def test_use_async_effect_cleanup_task(): component_hook = HookCatcher() effect_ran = WaitForEvent()