From 61a7da37bfa9f8c1ce66629cb98474d2c3587555 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Fri, 7 Jul 2023 02:04:38 -0600 Subject: [PATCH 01/19] initial implementation --- src/py/reactpy/reactpy/core/hooks.py | 150 ++++++++++++-------------- src/py/reactpy/reactpy/core/layout.py | 68 ++++++------ 2 files changed, 102 insertions(+), 116 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a8334458b..f9362187b 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Sequence +from collections.abc import Coroutine, Sequence +from dataclasses import dataclass from logging import getLogger from types import FunctionType from typing import ( @@ -9,12 +10,12 @@ Any, Callable, Generic, - NewType, Protocol, TypeVar, cast, overload, ) +from weakref import WeakSet from typing_extensions import TypeAlias @@ -96,30 +97,30 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: _EffectCleanFunc: TypeAlias = "Callable[[], None]" _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" -_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" +_AsyncEffectFunc: TypeAlias = "Callable[[asyncio.Event], Coroutine[None, None, None]]" +_EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @overload def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None]: +) -> Callable[[_EffectFunc], None]: ... @overload def use_effect( - function: _EffectApplyFunc, + function: _EffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectApplyFunc | None = None, + function: _EffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: +) -> Callable[[_EffectFunc], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -135,37 +136,25 @@ def use_effect( If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) - - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) - - def sync_function() -> _EffectCleanFunc | None: - future = asyncio.ensure_future(async_function()) + effect_info: Ref[_EffectInfo | None] = use_ref(None) - def clean_future() -> None: - if not future.cancel(): - clean = future.result() - if clean is not None: - clean() + def add_effect(function: _EffectFunc) -> None: + effect = _cast_async_effect(function) - return clean_future + async def create_effect_task() -> _EffectInfo: + if effect_info.current is not None: + last_effect_info = effect_info.current + last_effect_info.stop.set() + await last_effect_info.task - def effect() -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() + stop = asyncio.Event() + info = _EffectInfo(asyncio.create_task(effect(stop)), stop) + effect_info.current = info + return info - clean = last_clean_callback.current = sync_function() - if clean is not None: - hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) - - return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) + return memoize(lambda: hook.add_effect(create_effect_task)) if function is not None: add_effect(function) @@ -174,6 +163,19 @@ def effect() -> None: return add_effect +def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: + if asyncio.iscoroutinefunction(function): + return function + + async def wrapper(stop: asyncio.Event) -> None: + cleanup = function() + await stop.wait() + if cleanup is not None: + cleanup() + + return wrapper + + def use_debug_value( message: Any | Callable[[], Any], dependencies: Sequence[Any] | ellipsis | None = ..., @@ -507,19 +509,6 @@ def current_hook() -> LifeCycleHook: _hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) -EffectType = NewType("EffectType", str) -"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved""" - -COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER") -"""An effect that will be triggered each time a component renders""" - -LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER") -"""An effect that will be triggered each time a layout renders""" - -COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT") -"""An effect that will be triggered just before the component is unmounted""" - - class LifeCycleHook: """Defines the life cycle of a layout component. @@ -590,7 +579,8 @@ class LifeCycleHook: "__weakref__", "_context_providers", "_current_state_index", - "_event_effects", + "_effect_funcs", + "_effect_infos", "_is_rendering", "_rendered_atleast_once", "_schedule_render_callback", @@ -612,11 +602,8 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () - self._event_effects: dict[EffectType, list[Callable[[], None]]] = { - COMPONENT_DID_RENDER_EFFECT: [], - LAYOUT_DID_RENDER_EFFECT: [], - COMPONENT_WILL_UNMOUNT_EFFECT: [], - } + self._effect_funcs: list[_EffectStarter] = [] + self._effect_infos: WeakSet[_EffectInfo] = WeakSet() def schedule_render(self) -> None: if self._is_rendering: @@ -635,9 +622,9 @@ def use_state(self, function: Callable[[], _Type]) -> _Type: self._current_state_index += 1 return result - def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None: + def add_effect(self, start_effect: _EffectStarter) -> None: """Trigger a function on the occurrence of the given effect type""" - self._event_effects[effect_type].append(function) + self._effect_funcs.append(start_effect) def set_context_provider(self, provider: ContextProvider[Any]) -> None: self._context_providers[provider.type] = provider @@ -647,52 +634,40 @@ def get_context_provider( ) -> ContextProvider[_Type] | None: return self._context_providers.get(context) - def affect_component_will_render(self, component: ComponentType) -> None: + async def affect_component_will_render(self, component: ComponentType) -> None: """The component is about to render""" self.component = component - self._is_rendering = True - self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear() + self.set_current() - def affect_component_did_render(self) -> None: + async def affect_component_did_render(self) -> None: """The component completed a render""" + self.unset_current() del self.component - - component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT] - for effect in component_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Component post-render effect {effect} failed") - component_did_render_effects.clear() - self._is_rendering = False self._rendered_atleast_once = True self._current_state_index = 0 - def affect_layout_did_render(self) -> None: + async def affect_layout_did_render(self) -> None: """The layout completed a render""" - layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT] - for effect in layout_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Layout post-render effect {effect} failed") - layout_did_render_effects.clear() + for start_effect in self._effect_funcs: + effect_info = await start_effect() + self._effect_infos.add(effect_info) + self._effect_funcs.clear() if self._schedule_render_later: self._schedule_render() self._schedule_render_later = False - def affect_component_will_unmount(self) -> None: + async def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" - will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT] - for effect in will_unmount_effects: - try: - effect() - except Exception: - logger.exception(f"Pre-unmount effect {effect} failed") - will_unmount_effects.clear() + for infos in self._effect_infos: + infos.stop.set() + try: + await asyncio.gather(*[i.task for i in self._effect_infos]) + except Exception: + logger.exception("Error during effect cancellation") + self._effect_infos.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread @@ -720,6 +695,15 @@ def _schedule_render(self) -> None: ) +_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, _EffectInfo]]" + + +@dataclass(frozen=True) +class _EffectInfo: + task: asyncio.Task[None] + stop: asyncio.Event + + def strictly_equal(x: Any, y: Any) -> bool: """Check if two values are identical or, for a limited set or types, equal. diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f84cb104e..800dadbfb 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -4,7 +4,7 @@ import asyncio from collections import Counter from collections.abc import Iterator -from contextlib import ExitStack +from contextlib import AsyncExitStack from logging import getLogger from typing import ( Any, @@ -72,7 +72,7 @@ async def __aenter__(self) -> Layout: async def __aexit__(self, *exc: Any) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] - self._unmount_model_states([root_model_state]) + await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager del self._event_handlers @@ -111,19 +111,21 @@ async def render(self) -> LayoutUpdateMessage: f"{model_state_id!r} - component already unmounted" ) else: - update = self._create_layout_update(model_state) + update = await self._create_layout_update(model_state) if REACTPY_CHECK_VDOM_SPEC.current: root_id = self._root_life_cycle_state_id root_model = self._model_states_by_life_cycle_state_id[root_id] validate_vdom_json(root_model.model.current) return update - def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: + async def _create_layout_update( + self, old_state: _ModelState + ) -> LayoutUpdateMessage: new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component - with ExitStack() as exit_stack: - self._render_component(exit_stack, old_state, new_state, component) + async with AsyncExitStack() as exit_stack: + await self._render_component(exit_stack, old_state, new_state, component) return { "type": "layout-update", @@ -131,9 +133,9 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: "model": new_state.model.current, } - def _render_component( + async def _render_component( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, component: ComponentType, @@ -143,9 +145,8 @@ def _render_component( self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state - life_cycle_hook.affect_component_will_render(component) - exit_stack.callback(life_cycle_hook.affect_layout_did_render) - life_cycle_hook.set_current() + await life_cycle_hook.affect_component_will_render(component) + exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render) try: raw_model = component.render() # wrap the model in a fragment (i.e. tagName="") to ensure components have @@ -154,7 +155,7 @@ def _render_component( wrapper_model: VdomDict = {"tagName": ""} if raw_model is not None: wrapper_model["children"] = [raw_model] - self._render_model(exit_stack, old_state, new_state, wrapper_model) + await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -166,8 +167,7 @@ def _render_component( ), } finally: - life_cycle_hook.unset_current() - life_cycle_hook.affect_component_did_render() + await life_cycle_hook.affect_component_did_render() try: parent = new_state.parent @@ -188,9 +188,9 @@ def _render_component( ], } - def _render_model( + async def _render_model( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_model: Any, @@ -205,7 +205,7 @@ def _render_model( if "importSource" in raw_model: new_state.model.current["importSource"] = raw_model["importSource"] self._render_model_attributes(old_state, new_state, raw_model) - self._render_model_children( + await self._render_model_children( exit_stack, old_state, new_state, raw_model.get("children", []) ) @@ -272,9 +272,9 @@ def _render_model_event_handlers_without_old_state( return None - def _render_model_children( + async def _render_model_children( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_children: Any, @@ -284,12 +284,12 @@ def _render_model_children( if old_state is None: if raw_children: - self._render_model_children_without_old_state( + await self._render_model_children_without_old_state( exit_stack, new_state, raw_children ) return None elif not raw_children: - self._unmount_model_states(list(old_state.children_by_key.values())) + await self._unmount_model_states(list(old_state.children_by_key.values())) return None child_type_key_tuples = list(_process_child_type_and_key(raw_children)) @@ -303,7 +303,7 @@ def _render_model_children( old_keys = set(old_state.children_by_key).difference(new_keys) if old_keys: - self._unmount_model_states( + await self._unmount_model_states( [old_state.children_by_key[key] for key in old_keys] ) @@ -319,7 +319,7 @@ def _render_model_children( key, ) elif old_child_state.is_component_state: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_child_state = _make_element_model_state( new_state, index, @@ -332,7 +332,9 @@ def _render_model_children( new_state, index, ) - self._render_model(exit_stack, old_child_state, new_child_state, child) + await self._render_model( + exit_stack, old_child_state, new_child_state, child + ) new_state.append_child(new_child_state.model.current) new_state.children_by_key[key] = new_child_state elif child_type is _COMPONENT_TYPE: @@ -349,7 +351,7 @@ def _render_model_children( elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type ): - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) old_child_state = None new_child_state = _make_component_model_state( new_state, @@ -366,18 +368,18 @@ def _render_model_children( child, self._rendering_queue.put, ) - self._render_component( + await self._render_component( exit_stack, old_child_state, new_child_state, child ) else: old_child_state = old_state.children_by_key.get(key) if old_child_state is not None: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_state.append_child(child) - def _render_model_children_without_old_state( + async def _render_model_children_without_old_state( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, new_state: _ModelState, raw_children: list[Any], ) -> None: @@ -394,18 +396,18 @@ def _render_model_children_without_old_state( for index, (child, child_type, key) in enumerate(child_type_key_tuples): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) - self._render_model(exit_stack, None, child_state, child) + await self._render_model(exit_stack, None, child_state, child) new_state.append_child(child_state.model.current) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( new_state, index, key, child, self._rendering_queue.put ) - self._render_component(exit_stack, None, child_state, child) + await self._render_component(exit_stack, None, child_state, child) else: new_state.append_child(child) - def _unmount_model_states(self, old_states: list[_ModelState]) -> None: + async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount = old_states[::-1] # unmount in reversed order of rendering while to_unmount: model_state = to_unmount.pop() @@ -416,7 +418,7 @@ def _unmount_model_states(self, old_states: list[_ModelState]) -> None: if model_state.is_component_state: life_cycle_state = model_state.life_cycle_state del self._model_states_by_life_cycle_state_id[life_cycle_state.id] - life_cycle_state.hook.affect_component_will_unmount() + await life_cycle_state.hook.affect_component_will_unmount() to_unmount.extend(model_state.children_by_key.values()) From 19707087213117bfefa8b069e19ec75f228ef33f Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Fri, 7 Jul 2023 22:57:00 -0600 Subject: [PATCH 02/19] fix tests --- src/py/reactpy/reactpy/core/hooks.py | 49 ++++++++-- src/py/reactpy/tests/conftest.py | 2 +- src/py/reactpy/tests/test_core/test_hooks.py | 93 +++++++----------- src/py/reactpy/tests/test_core/test_layout.py | 15 ++- src/py/reactpy/tests/tooling/concurrency.py | 98 +++++++++++++++++++ 5 files changed, 187 insertions(+), 70 deletions(-) create mode 100644 src/py/reactpy/tests/tooling/concurrency.py diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index f9362187b..3b9fbd4ea 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import inspect +import warnings from collections.abc import Coroutine, Sequence from dataclasses import dataclass from logging import getLogger @@ -164,16 +166,47 @@ async def create_effect_task() -> _EffectInfo: def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: - if asyncio.iscoroutinefunction(function): - return function + if inspect.iscoroutinefunction(function): + if len(inspect.signature(function).parameters): + return function - async def wrapper(stop: asyncio.Event) -> None: - cleanup = function() - await stop.wait() - if cleanup is not None: - cleanup() + warnings.warn( + 'Async effect functions should accept a "stop" asyncio.Event as their first argument', + stacklevel=3, + ) + + async def wrapper(stop: asyncio.Event) -> None: + task = asyncio.create_task(function()) + await stop.wait() + if not task.cancel(): + try: + cleanup = await task + except Exception: + logger.exception("Error while applying effect") + return + if cleanup is not None: + try: + cleanup() + except Exception: + logger.exception("Error while cleaning up effect") + + return wrapper + else: + + async def wrapper(stop: asyncio.Event) -> None: + try: + cleanup = function() + except Exception: + logger.exception("Error while applying effect") + return + await stop.wait() + try: + if cleanup is not None: + cleanup() + except Exception: + logger.exception("Error while cleaning up effect") - return wrapper + return wrapper def use_debug_value( diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index 21b23c12e..527d16c7a 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -15,7 +15,7 @@ capture_reactpy_logs, clear_reactpy_web_modules_dir, ) -from tests.tooling.loop import open_event_loop +from tests.tooling.concurrency import open_event_loop def pytest_addoption(parser: Parser) -> None: diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 453d07c99..393fddd7a 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -5,17 +5,13 @@ import reactpy from reactpy import html from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.hooks import ( - COMPONENT_DID_RENDER_EFFECT, - LifeCycleHook, - current_hook, - strictly_equal, -) +from reactpy.core.hooks import LifeCycleHook, 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 from reactpy.utils import Ref from tests.tooling.common import DEFAULT_TYPE_DELAY, update_message +from tests.tooling.concurrency import WaitForEvent async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -327,7 +323,7 @@ def CheckNoEffectYet(): async def test_use_effect_cleanup_occurs_before_next_effect(): component_hook = HookCatcher() cleanup_triggered = reactpy.Ref(False) - cleanup_triggered_before_next_effect = reactpy.Ref(False) + cleanup_triggered_before_next_effect = WaitForEvent() @reactpy.component @component_hook.capture @@ -335,7 +331,7 @@ def ComponentWithEffect(): @reactpy.hooks.use_effect(dependencies=None) def effect(): if cleanup_triggered.current: - cleanup_triggered_before_next_effect.current = True + cleanup_triggered_before_next_effect.set() def cleanup(): cleanup_triggered.current = True @@ -353,7 +349,7 @@ def cleanup(): await layout.render() assert cleanup_triggered.current - assert cleanup_triggered_before_next_effect.current + await cleanup_triggered_before_next_effect.wait() async def test_use_effect_cleanup_occurs_on_will_unmount(): @@ -395,10 +391,11 @@ def cleanup(): assert cleanup_triggered_before_next_render.current -async def test_memoized_effect_on_recreated_if_dependencies_change(): +async def test_memoized_effect_is_recreated_if_dependencies_change(): component_hook = HookCatcher() set_state_callback = reactpy.Ref(None) - effect_run_count = reactpy.Ref(0) + effect_ran = WaitForEvent() + run_count = 0 first_value = 1 second_value = 2 @@ -410,29 +407,31 @@ def ComponentWithMemoizedEffect(): @reactpy.hooks.use_effect(dependencies=[state]) def effect(): - effect_run_count.current += 1 + nonlocal run_count + effect_ran.set() + run_count += 1 return reactpy.html.div() async with reactpy.Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() - assert effect_run_count.current == 1 + await effect_ran.wait() + effect_ran.clear() component_hook.latest.schedule_render() await layout.render() - assert effect_run_count.current == 1 - set_state_callback.current(second_value) await layout.render() - assert effect_run_count.current == 2 + await effect_ran.wait() + effect_ran.clear() component_hook.latest.schedule_render() await layout.render() - assert effect_run_count.current == 2 + assert run_count == 2 async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): @@ -474,7 +473,7 @@ def cleanup(): async def test_use_async_effect(): - effect_ran = asyncio.Event() + effect_ran = WaitForEvent() @reactpy.component def ComponentWithAsyncEffect(): @@ -486,13 +485,13 @@ async def effect(): async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() - await asyncio.wait_for(effect_ran.wait(), 1) + await effect_ran.wait() async def test_use_async_effect_cleanup(): component_hook = HookCatcher() - effect_ran = asyncio.Event() - cleanup_ran = asyncio.Event() + effect_ran = WaitForEvent() + cleanup_ran = WaitForEvent() @reactpy.component @component_hook.capture @@ -516,10 +515,10 @@ async def effect(): async def test_use_async_effect_cancel(caplog): component_hook = HookCatcher() - effect_ran = asyncio.Event() - effect_was_cancelled = asyncio.Event() + effect_ran = WaitForEvent() + effect_was_cancelled = WaitForEvent() - event_that_never_occurs = asyncio.Event() + event_that_never_occurs = WaitForEvent() @reactpy.component @component_hook.capture @@ -562,7 +561,7 @@ def bad_effect(): return reactpy.html.div() - with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"): + with assert_reactpy_did_log(match_message=r"Error while applying effect"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -588,7 +587,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Pre-unmount effect .*? failed", + match_message=r"Error while cleaning up effect", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -845,7 +844,7 @@ def bad_callback(): async def test_use_effect_automatically_infers_closure_values(): set_count = reactpy.Ref() - did_effect = asyncio.Event() + did_effect = WaitForEvent() @reactpy.component def CounterWithEffect(): @@ -873,7 +872,7 @@ def some_effect_that_uses_count(): async def test_use_memo_automatically_infers_closure_values(): set_count = reactpy.Ref() - did_memo = asyncio.Event() + did_memo = WaitForEvent() @reactpy.component def CounterWithEffect(): @@ -1001,13 +1000,16 @@ async def test_error_in_layout_effect_cleanup_is_gracefully_handled(): def ComponentWithEffect(): @reactpy.hooks.use_effect(dependencies=None) # always run def bad_effect(): - msg = "The error message" - raise ValueError(msg) + def bad_cleanup(): + msg = "The error message" + raise ValueError(msg) + + return bad_cleanup return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"post-render effect .*? failed", + match_message=r"Error while cleaning up effect", error_type=ValueError, match_error="The error message", ): @@ -1211,12 +1213,12 @@ def incr_effect_count(): async with reactpy.Layout(SomeComponent()) as layout: await layout.render() - assert effect_count.current == 1 + await poll(lambda: effect_count.current).until_equals(1) value.current = "string" # new string instance but same value hook.latest.schedule_render() await layout.render() # effect does not trigger - assert effect_count.current == 1 + await poll(lambda: effect_count.current).until_equals(1) async def test_use_state_named_tuple(): @@ -1232,28 +1234,3 @@ def some_component(): state.current.set_value(2) await layout.render() assert state.current.value == 2 - - -async def test_error_in_component_effect_cleanup_is_gracefully_handled(): - component_hook = HookCatcher() - - @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - hook = current_hook() - - def bad_effect(): - raise ValueError("The error message") - - hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) - return reactpy.html.div() - - with assert_reactpy_did_log( - match_message="Component post-render effect .*? failed", - error_type=ValueError, - match_error="The error message", - ): - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() # no error diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 215e89137..41c81cad5 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -20,6 +20,7 @@ assert_reactpy_did_log, capture_reactpy_logs, ) +from reactpy.testing.common import poll from reactpy.utils import Ref from tests.tooling import select from tests.tooling.common import event_message, update_message @@ -828,20 +829,28 @@ def some_effect(): return reactpy.html.div(name) + poll_effects = poll(lambda: effects) + async with reactpy.Layout(Root()) as layout: await layout.render() - assert effects == ["mount x"] + await poll_effects.until_equals( + ["mount x"], + ) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y"] + await poll_effects.until_equals( + ["mount x", "unmount x", "mount y"], + ) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"] + await poll_effects.until_equals( + ["mount x", "unmount x", "mount y", "unmount y", "mount x"], + ) async def test_layout_does_not_copy_element_children_by_key(): diff --git a/src/py/reactpy/tests/tooling/concurrency.py b/src/py/reactpy/tests/tooling/concurrency.py new file mode 100644 index 000000000..5391ede30 --- /dev/null +++ b/src/py/reactpy/tests/tooling/concurrency.py @@ -0,0 +1,98 @@ +import asyncio +import threading +import time +from asyncio import Event, wait_for +from collections.abc import Iterator +from contextlib import contextmanager + +from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT + + +class WaitForEvent(Event): + """Event where the wait method has a timeout.""" + + async def wait(self, timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current): + return await wait_for(super().wait(), timeout=timeout) + + +@contextmanager +def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: + """Open a new event loop and cleanly stop it + + Args: + as_current: whether to make this loop the current loop in this thread + """ + loop = asyncio.new_event_loop() + try: + if as_current: + asyncio.set_event_loop(loop) + loop.set_debug(True) + yield loop + finally: + try: + _cancel_all_tasks(loop, as_current) + if as_current: + loop.run_until_complete( + wait_for( + loop.shutdown_asyncgens(), + REACTPY_TESTING_DEFAULT_TIMEOUT.current, + ) + ) + loop.run_until_complete( + wait_for( + loop.shutdown_default_executor(), + REACTPY_TESTING_DEFAULT_TIMEOUT.current, + ) + ) + finally: + if as_current: + asyncio.set_event_loop(None) + start = time.time() + while loop.is_running(): + if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current: + msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds" + raise TimeoutError(msg) + time.sleep(0.1) + loop.close() + + +def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + done = threading.Event() + count = len(to_cancel) + + def one_task_finished(future): + nonlocal count + count -= 1 + if count == 0: + done.set() + + for task in to_cancel: + loop.call_soon_threadsafe(task.cancel) + task.add_done_callback(one_task_finished) + + if is_current: + loop.run_until_complete( + wait_for( + asyncio.gather(*to_cancel, return_exceptions=True), + REACTPY_TESTING_DEFAULT_TIMEOUT.current, + ) + ) + elif not done.wait(timeout=3): # user was responsible for cancelling all tasks + msg = "Could not stop event loop in time" + raise TimeoutError(msg) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during event loop shutdown", + "exception": task.exception(), + "task": task, + } + ) From 1d4ed4ce56edd8c778fd21cc744b844fb0aac26f Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 8 Jul 2023 21:22:21 -0600 Subject: [PATCH 03/19] fix doctest --- src/py/reactpy/reactpy/core/hooks.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 3b9fbd4ea..f3389da7f 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -572,10 +572,8 @@ class LifeCycleHook: # --- start render cycle --- - hook.affect_component_will_render(...) - - hook.set_current() - + component = ... + await hook.affect_component_will_render(component) try: # render the component ... @@ -587,13 +585,11 @@ class LifeCycleHook: current_hook().use_state(lambda: ...) current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) finally: - hook.unset_current() - - hook.affect_component_did_render() + await hook.affect_component_did_render() # This should only be called after the full set of changes associated with a # given render have been completed. - hook.affect_layout_did_render() + await hook.affect_layout_did_render() # Typically an event occurs and a new render is scheduled, thus beginning # the render cycle anew. From 53ba2201ebca1a0b04d552a2a8b5e584df97291e Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 15 Jul 2023 12:32:44 -0600 Subject: [PATCH 04/19] make life cycle hook private (for now) --- .../reactpy/reactpy/core/_life_cycle_hook.py | 251 +++++++++++++++++ src/py/reactpy/reactpy/core/hooks.py | 254 +----------------- 2 files changed, 261 insertions(+), 244 deletions(-) create mode 100644 src/py/reactpy/reactpy/core/_life_cycle_hook.py diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py new file mode 100644 index 000000000..fbad0c9b7 --- /dev/null +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from dataclasses import dataclass +from typing import Any, Callable, Generic, Protocol, TypeVar +from weakref import WeakSet + +from typing_extensions import TypeAlias + +from reactpy.core._thread_local import ThreadLocal +from reactpy.core.types import ComponentType, Key, VdomDict + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +def current_hook() -> LifeCycleHook: + """Get the current :class:`LifeCycleHook`""" + hook_stack = _hook_stack.get() + if not hook_stack: + msg = "No life cycle hook is active. Are you rendering in a layout?" + raise RuntimeError(msg) + return hook_stack[-1] + + +_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) + + +class Context(Protocol[T]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: T = ..., + key: Key | None = ..., + ) -> ContextProvider[T]: + ... + + +class ContextProvider(Generic[T]): + def __init__( + self, + *children: Any, + value: T, + key: Key | None, + type: Context[T], + ) -> None: + self.children = children + self.key = key + self.type = type + self._value = value + + def render(self) -> VdomDict: + current_hook().set_context_provider(self) + return {"tagName": "", "children": self.children} + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.type})" + + +@dataclass(frozen=True) +class EffectInfo: + task: asyncio.Task[None] + stop: asyncio.Event + + +class LifeCycleHook: + """Defines the life cycle of a layout component. + + Components can request access to their own life cycle events and state through hooks + while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle + forward by triggering events and rendering view changes. + + Example: + + If removed from the complexities of a layout, a very simplified full life cycle + for a single component with no child components would look a bit like this: + + .. testcode:: + + from reactpy.core.hooks import ( + current_hook, + LifeCycleHook, + COMPONENT_DID_RENDER_EFFECT, + ) + + + # this function will come from a layout implementation + schedule_render = lambda: ... + + # --- start life cycle --- + + hook = LifeCycleHook(schedule_render) + + # --- start render cycle --- + + component = ... + await hook.affect_component_will_render(component) + try: + # render the component + ... + + # the component may access the current hook + assert current_hook() is hook + + # and save state or add effects + current_hook().use_state(lambda: ...) + current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) + finally: + await hook.affect_component_did_render() + + # This should only be called after the full set of changes associated with a + # given render have been completed. + await hook.affect_layout_did_render() + + # Typically an event occurs and a new render is scheduled, thus beginning + # the render cycle anew. + hook.schedule_render() + + + # --- end render cycle --- + + hook.affect_component_will_unmount() + del hook + + # --- end render cycle --- + """ + + __slots__ = ( + "__weakref__", + "_context_providers", + "_current_state_index", + "_effect_funcs", + "_effect_infos", + "_is_rendering", + "_rendered_atleast_once", + "_schedule_render_callback", + "_schedule_render_later", + "_state", + "component", + ) + + component: ComponentType + + def __init__( + self, + schedule_render: Callable[[], None], + ) -> None: + self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} + self._schedule_render_callback = schedule_render + self._schedule_render_later = False + self._is_rendering = False + self._rendered_atleast_once = False + self._current_state_index = 0 + self._state: tuple[Any, ...] = () + self._effect_funcs: list[_EffectStarter] = [] + self._effect_infos: WeakSet[EffectInfo] = WeakSet() + + def schedule_render(self) -> None: + if self._is_rendering: + self._schedule_render_later = True + else: + self._schedule_render() + + def use_state(self, function: Callable[[], T]) -> T: + if not self._rendered_atleast_once: + # since we're not initialized yet we're just appending state + result = function() + self._state += (result,) + else: + # once finalized we iterate over each succesively used piece of state + result = self._state[self._current_state_index] + self._current_state_index += 1 + return result + + def add_effect(self, start_effect: _EffectStarter) -> None: + """Trigger a function on the occurrence of the given effect type""" + self._effect_funcs.append(start_effect) + + def set_context_provider(self, provider: ContextProvider[Any]) -> None: + self._context_providers[provider.type] = provider + + def get_context_provider(self, context: Context[T]) -> ContextProvider[T] | None: + return self._context_providers.get(context) + + async def affect_component_will_render(self, component: ComponentType) -> None: + """The component is about to render""" + self.component = component + self._is_rendering = True + self.set_current() + + async def affect_component_did_render(self) -> None: + """The component completed a render""" + self.unset_current() + del self.component + self._is_rendering = False + self._rendered_atleast_once = True + self._current_state_index = 0 + + async def affect_layout_did_render(self) -> None: + """The layout completed a render""" + for start_effect in self._effect_funcs: + effect_info = await start_effect() + self._effect_infos.add(effect_info) + self._effect_funcs.clear() + + if self._schedule_render_later: + self._schedule_render() + self._schedule_render_later = False + + async def affect_component_will_unmount(self) -> None: + """The component is about to be removed from the layout""" + for infos in self._effect_infos: + infos.stop.set() + try: + await asyncio.gather(*[i.task for i in self._effect_infos]) + except Exception: + logger.exception("Error during effect cancellation") + self._effect_infos.clear() + + def set_current(self) -> None: + """Set this hook as the active hook in this thread + + This method is called by a layout before entering the render method + of this hook's associated component. + """ + hook_stack = _hook_stack.get() + if hook_stack: + parent = hook_stack[-1] + self._context_providers.update(parent._context_providers) + hook_stack.append(self) + + def unset_current(self) -> None: + """Unset this hook as the active hook in this thread""" + if _hook_stack.get().pop() is not self: + raise RuntimeError("Hook stack is in an invalid state") # nocov + + def _schedule_render(self) -> None: + try: + self._schedule_render_callback() + except Exception: + logger.exception( + f"Failed to schedule render via {self._schedule_render_callback}" + ) + + +_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, EffectInfo]]" diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index f3389da7f..69bb37c52 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -4,7 +4,6 @@ import inspect import warnings from collections.abc import Coroutine, Sequence -from dataclasses import dataclass from logging import getLogger from types import FunctionType from typing import ( @@ -17,20 +16,23 @@ cast, overload, ) -from weakref import WeakSet from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Key, State, VdomDict +from reactpy.core._life_cycle_hook import ( + Context, + ContextProvider, + EffectInfo, + current_hook, +) +from reactpy.core.types import Key, State from reactpy.utils import Ref if not TYPE_CHECKING: # make flake8 think that this variable exists ellipsis = type(...) - __all__ = [ "use_state", "use_effect", @@ -140,19 +142,19 @@ def use_effect( hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - effect_info: Ref[_EffectInfo | None] = use_ref(None) + effect_info: Ref[EffectInfo | None] = use_ref(None) def add_effect(function: _EffectFunc) -> None: effect = _cast_async_effect(function) - async def create_effect_task() -> _EffectInfo: + async def create_effect_task() -> EffectInfo: if effect_info.current is not None: last_effect_info = effect_info.current last_effect_info.stop.set() await last_effect_info.task stop = asyncio.Event() - info = _EffectInfo(asyncio.create_task(effect(stop)), stop) + info = EffectInfo(asyncio.create_task(effect(stop)), stop) effect_info.current = info return info @@ -260,18 +262,6 @@ def context( return context -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProvider[_Type]: - ... - - def use_context(context: Context[_Type]) -> _Type: """Get the current value for the given context type. @@ -293,27 +283,6 @@ def use_context(context: Context[_Type]) -> _Type: return provider._value -class ContextProvider(Generic[_Type]): - def __init__( - self, - *children: Any, - value: _Type, - key: Key | None, - type: Context[_Type], - ) -> None: - self.children = children - self.key = key - self.type = type - self._value = value - - def render(self) -> VdomDict: - current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" - - _ActionType = TypeVar("_ActionType") @@ -530,209 +499,6 @@ def _try_to_infer_closure_values( return values -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_stack.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] - - -_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) - - -class LifeCycleHook: - """Defines the life cycle of a layout component. - - Components can request access to their own life cycle events and state through hooks - while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle - forward by triggering events and rendering view changes. - - Example: - - If removed from the complexities of a layout, a very simplified full life cycle - for a single component with no child components would look a bit like this: - - .. testcode:: - - from reactpy.core.hooks import ( - current_hook, - LifeCycleHook, - COMPONENT_DID_RENDER_EFFECT, - ) - - - # this function will come from a layout implementation - schedule_render = lambda: ... - - # --- start life cycle --- - - hook = LifeCycleHook(schedule_render) - - # --- start render cycle --- - - component = ... - await hook.affect_component_will_render(component) - try: - # render the component - ... - - # the component may access the current hook - assert current_hook() is hook - - # and save state or add effects - current_hook().use_state(lambda: ...) - current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) - finally: - await hook.affect_component_did_render() - - # This should only be called after the full set of changes associated with a - # given render have been completed. - await hook.affect_layout_did_render() - - # Typically an event occurs and a new render is scheduled, thus beginning - # the render cycle anew. - hook.schedule_render() - - - # --- end render cycle --- - - hook.affect_component_will_unmount() - del hook - - # --- end render cycle --- - """ - - __slots__ = ( - "__weakref__", - "_context_providers", - "_current_state_index", - "_effect_funcs", - "_effect_infos", - "_is_rendering", - "_rendered_atleast_once", - "_schedule_render_callback", - "_schedule_render_later", - "_state", - "component", - ) - - component: ComponentType - - def __init__( - self, - schedule_render: Callable[[], None], - ) -> None: - self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} - self._schedule_render_callback = schedule_render - self._schedule_render_later = False - self._is_rendering = False - self._rendered_atleast_once = False - self._current_state_index = 0 - self._state: tuple[Any, ...] = () - self._effect_funcs: list[_EffectStarter] = [] - self._effect_infos: WeakSet[_EffectInfo] = WeakSet() - - def schedule_render(self) -> None: - if self._is_rendering: - self._schedule_render_later = True - else: - self._schedule_render() - - def use_state(self, function: Callable[[], _Type]) -> _Type: - if not self._rendered_atleast_once: - # since we're not initialized yet we're just appending state - result = function() - self._state += (result,) - else: - # once finalized we iterate over each succesively used piece of state - result = self._state[self._current_state_index] - self._current_state_index += 1 - return result - - def add_effect(self, start_effect: _EffectStarter) -> None: - """Trigger a function on the occurrence of the given effect type""" - self._effect_funcs.append(start_effect) - - def set_context_provider(self, provider: ContextProvider[Any]) -> None: - self._context_providers[provider.type] = provider - - def get_context_provider( - self, context: Context[_Type] - ) -> ContextProvider[_Type] | None: - return self._context_providers.get(context) - - async def affect_component_will_render(self, component: ComponentType) -> None: - """The component is about to render""" - self.component = component - self._is_rendering = True - self.set_current() - - async def affect_component_did_render(self) -> None: - """The component completed a render""" - self.unset_current() - del self.component - self._is_rendering = False - self._rendered_atleast_once = True - self._current_state_index = 0 - - async def affect_layout_did_render(self) -> None: - """The layout completed a render""" - for start_effect in self._effect_funcs: - effect_info = await start_effect() - self._effect_infos.add(effect_info) - self._effect_funcs.clear() - - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False - - async def affect_component_will_unmount(self) -> None: - """The component is about to be removed from the layout""" - for infos in self._effect_infos: - infos.stop.set() - try: - await asyncio.gather(*[i.task for i in self._effect_infos]) - except Exception: - logger.exception("Error during effect cancellation") - self._effect_infos.clear() - - def set_current(self) -> None: - """Set this hook as the active hook in this thread - - This method is called by a layout before entering the render method - of this hook's associated component. - """ - hook_stack = _hook_stack.get() - if hook_stack: - parent = hook_stack[-1] - self._context_providers.update(parent._context_providers) - hook_stack.append(self) - - def unset_current(self) -> None: - """Unset this hook as the active hook in this thread""" - if _hook_stack.get().pop() is not self: - raise RuntimeError("Hook stack is in an invalid state") # nocov - - def _schedule_render(self) -> None: - try: - self._schedule_render_callback() - except Exception: - logger.exception( - f"Failed to schedule render via {self._schedule_render_callback}" - ) - - -_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, _EffectInfo]]" - - -@dataclass(frozen=True) -class _EffectInfo: - task: asyncio.Task[None] - stop: asyncio.Event - - def strictly_equal(x: Any, y: Any) -> bool: """Check if two values are identical or, for a limited set or types, equal. From 2d0c1ae78672f57ffa5826badccff024c35c88a5 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 15 Jul 2023 13:23:57 -0600 Subject: [PATCH 05/19] make LifeCycleHook private + add timeout to async effects --- src/py/reactpy/reactpy/backend/hooks.py | 3 +- src/py/reactpy/reactpy/config.py | 8 +++ .../reactpy/reactpy/core/_life_cycle_hook.py | 59 +++++++------------ src/py/reactpy/reactpy/core/hooks.py | 52 +++++++++++----- src/py/reactpy/reactpy/core/layout.py | 2 +- src/py/reactpy/reactpy/core/types.py | 24 ++++++++ src/py/reactpy/reactpy/testing/common.py | 2 +- src/py/reactpy/reactpy/types.py | 2 +- src/py/reactpy/tests/test_core/test_hooks.py | 3 +- 9 files changed, 97 insertions(+), 58 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index 19ad114ed..ee4ce1b5c 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -4,7 +4,8 @@ from typing import Any from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import Context, create_context, use_context +from reactpy.core.hooks import create_context, use_context +from reactpy.core.types import Context # backend implementations should establish this context at the root of an app ConnectionContext: Context[Connection[Any] | None] = create_context(None) diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 8371e6d08..a2a9fbcb2 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool: validator=float, ) """A default timeout for testing utilities in ReactPy""" + +REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT = Option( + "REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT", + 30.0, + mutable=False, + validator=float, +) +"""The default amount of time to wait for an effect to complete""" diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index fbad0c9b7..5ddd4fd49 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -4,16 +4,15 @@ import logging from collections.abc import Coroutine from dataclasses import dataclass -from typing import Any, Callable, Generic, Protocol, TypeVar +from typing import Any, Callable, TypeVar from weakref import WeakSet from typing_extensions import TypeAlias from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Key, VdomDict +from reactpy.core.types import ComponentType, Context, ContextProviderType T = TypeVar("T") - logger = logging.getLogger(__name__) @@ -29,44 +28,24 @@ def current_hook() -> LifeCycleHook: _hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) -class Context(Protocol[T]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: T = ..., - key: Key | None = ..., - ) -> ContextProvider[T]: - ... - - -class ContextProvider(Generic[T]): - def __init__( - self, - *children: Any, - value: T, - key: Key | None, - type: Context[T], - ) -> None: - self.children = children - self.key = key - self.type = type - self._value = value - - def render(self) -> VdomDict: - current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" - - @dataclass(frozen=True) class EffectInfo: task: asyncio.Task[None] stop: asyncio.Event + async def signal_stop(self, timeout: float) -> None: + """Signal the effect to stop and wait for it to complete.""" + self.stop.set() + try: + await asyncio.wait_for(self.task, timeout=timeout) + finally: + # a no-op if the task has already completed + if self.task.cancel(): + try: + await self.task + except asyncio.CancelledError: + logger.exception("Effect failed to stop after %s seconds", timeout) + class LifeCycleHook: """Defines the life cycle of a layout component. @@ -150,7 +129,7 @@ def __init__( self, schedule_render: Callable[[], None], ) -> None: - self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} + self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render self._schedule_render_later = False self._is_rendering = False @@ -181,10 +160,12 @@ def add_effect(self, start_effect: _EffectStarter) -> None: """Trigger a function on the occurrence of the given effect type""" self._effect_funcs.append(start_effect) - def set_context_provider(self, provider: ContextProvider[Any]) -> None: + def set_context_provider(self, provider: ContextProviderType[Any]) -> None: self._context_providers[provider.type] = provider - def get_context_provider(self, context: Context[T]) -> ContextProvider[T] | None: + def get_context_provider( + self, context: Context[T] + ) -> ContextProviderType[T] | None: return self._context_providers.get(context) async def affect_component_will_render(self, component: ComponentType) -> None: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 69bb37c52..ef41bb0ea 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -19,14 +19,9 @@ from typing_extensions import TypeAlias -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._life_cycle_hook import ( - Context, - ContextProvider, - EffectInfo, - current_hook, -) -from reactpy.core.types import Key, State +from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT +from reactpy.core._life_cycle_hook import EffectInfo, current_hook +from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: @@ -109,6 +104,7 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + stop_timeout: float = ..., ) -> Callable[[_EffectFunc], None]: ... @@ -117,6 +113,7 @@ def use_effect( def use_effect( function: _EffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., + stop_timeout: float = ..., ) -> None: ... @@ -124,6 +121,7 @@ def use_effect( def use_effect( function: _EffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + stop_timeout: float = REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT.current, ) -> Callable[[_EffectFunc], None] | None: """See the full :ref:`Use Effect` docs for details @@ -135,6 +133,11 @@ def use_effect( of any value in the given sequence changes (i.e. their :func:`id` is different). By default these are inferred based on local variables that are referenced by the given function. + stop_timeout: + The maximum amount of time to wait for the effect to cleanup after it has + been signaled to stop. If the timeout is reached, an exception will be + logged and the effect will be cancelled. This does not apply to synchronous + effects. Returns: If not function is provided, a decorator. Otherwise ``None``. @@ -150,8 +153,7 @@ def add_effect(function: _EffectFunc) -> None: async def create_effect_task() -> EffectInfo: if effect_info.current is not None: last_effect_info = effect_info.current - last_effect_info.stop.set() - await last_effect_info.task + await last_effect_info.signal_stop(stop_timeout) stop = asyncio.Event() info = EffectInfo(asyncio.create_task(effect(stop)), stop) @@ -173,7 +175,8 @@ def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: return function warnings.warn( - 'Async effect functions should accept a "stop" asyncio.Event as their first argument', + 'Async effect functions should accept a "stop" asyncio.Event as their ' + "first argument. This will be required in a future version of ReactPy.", stacklevel=3, ) @@ -249,8 +252,8 @@ def context( *children: Any, value: _Type = default_value, key: Key | None = None, - ) -> ContextProvider[_Type]: - return ContextProvider( + ) -> _ContextProvider[_Type]: + return _ContextProvider( *children, value=value, key=key, @@ -280,7 +283,28 @@ def use_context(context: Context[_Type]) -> _Type: raise TypeError(f"{context} has no 'value' kwarg") # nocov return cast(_Type, context.__kwdefaults__["value"]) - return provider._value + return provider.value + + +class _ContextProvider(Generic[_Type]): + def __init__( + self, + *children: Any, + value: _Type, + key: Key | None, + type: Context[_Type], + ) -> None: + self.children = children + self.key = key + self.type = type + self.value = value + + def render(self) -> VdomDict: + current_hook().set_context_provider(self) + return {"tagName": "", "children": self.children} + + def __repr__(self) -> str: + return f"ContextProvider({self.type})" _ActionType = TypeVar("_ActionType") diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 800dadbfb..aab43e3df 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -19,7 +19,7 @@ from weakref import ref as weakref from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE -from reactpy.core.hooks import LifeCycleHook +from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( ComponentType, EventHandlerDict, diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 194706c6e..91c05aa69 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -20,6 +20,7 @@ from typing_extensions import TypeAlias, TypedDict _Type = TypeVar("_Type") +_Type_invariant = TypeVar("_Type_invariant", covariant=False) if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): @@ -233,3 +234,26 @@ class LayoutEventMessage(TypedDict): """The ID of the event handler.""" data: Sequence[Any] """A list of event data passed to the event handler.""" + + +class Context(Protocol[_Type_invariant]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type_invariant = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type_invariant]: + ... + + +class ContextProviderType(ComponentType, Protocol[_Type]): + """A component which provides a context value to its children""" + + type: Context[_Type] + """The context type""" + + @property + def value(self) -> _Type: + "Current context value" diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index 6d126fd2e..c799a24ff 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -13,8 +13,8 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.hooks import LifeCycleHook, current_hook def clear_reactpy_web_modules_dir() -> None: diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index 715b66fff..86fe721cc 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -6,10 +6,10 @@ from reactpy.backend.types import BackendImplementation, Connection, Location from reactpy.core.component import Component -from reactpy.core.hooks import Context from reactpy.core.types import ( ComponentConstructor, ComponentType, + Context, EventHandlerDict, EventHandlerFunc, EventHandlerMapping, diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 393fddd7a..70e22f4c9 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -5,7 +5,8 @@ import reactpy from reactpy import html from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.hooks import LifeCycleHook, strictly_equal +from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core.hooks import 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 From 3d81311b5d0c27cda9e312495bd3951b636af23d Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 29 Oct 2023 13:53:49 -0700 Subject: [PATCH 06/19] async effect context --- pyproject.toml | 3 +- .../reactpy/reactpy/core/_life_cycle_hook.py | 69 +++----- src/py/reactpy/reactpy/core/hooks.py | 151 +++++++++++++----- src/py/reactpy/tests/test_core/test_hooks.py | 79 ++++++--- 4 files changed, 188 insertions(+), 114 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3cf94e23f..468fe26b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,8 +130,9 @@ ignore = [ "PLR0915", ] unfixable = [ - # Don't touch unused imports + # Don't touch unused imports or unused variables "F401", + "F841", ] [tool.ruff.isort] diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 5ddd4fd49..643eed4ad 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -3,50 +3,30 @@ import asyncio import logging from collections.abc import Coroutine -from dataclasses import dataclass from typing import Any, Callable, TypeVar -from weakref import WeakSet - -from typing_extensions import TypeAlias from reactpy.core._thread_local import ThreadLocal from reactpy.core.types import ComponentType, Context, ContextProviderType T = TypeVar("T") + +StopEffect = Callable[[], Coroutine[None, None, None]] +StartEffect = Callable[[], Coroutine[None, None, StopEffect]] + logger = logging.getLogger(__name__) +_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) + def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_stack.get() + hook_stack = _HOOK_STATE.get() if not hook_stack: msg = "No life cycle hook is active. Are you rendering in a layout?" raise RuntimeError(msg) return hook_stack[-1] -_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) - - -@dataclass(frozen=True) -class EffectInfo: - task: asyncio.Task[None] - stop: asyncio.Event - - async def signal_stop(self, timeout: float) -> None: - """Signal the effect to stop and wait for it to complete.""" - self.stop.set() - try: - await asyncio.wait_for(self.task, timeout=timeout) - finally: - # a no-op if the task has already completed - if self.task.cancel(): - try: - await self.task - except asyncio.CancelledError: - logger.exception("Effect failed to stop after %s seconds", timeout) - - class LifeCycleHook: """Defines the life cycle of a layout component. @@ -114,7 +94,8 @@ class LifeCycleHook: "_context_providers", "_current_state_index", "_effect_funcs", - "_effect_infos", + "_effect_starts", + "_effect_stops", "_is_rendering", "_rendered_atleast_once", "_schedule_render_callback", @@ -136,8 +117,8 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () - self._effect_funcs: list[_EffectStarter] = [] - self._effect_infos: WeakSet[EffectInfo] = WeakSet() + self._effect_starts: list[StartEffect] = [] + self._effect_stops: list[StopEffect] = [] def schedule_render(self) -> None: if self._is_rendering: @@ -156,9 +137,9 @@ def use_state(self, function: Callable[[], T]) -> T: self._current_state_index += 1 return result - def add_effect(self, start_effect: _EffectStarter) -> None: - """Trigger a function on the occurrence of the given effect type""" - self._effect_funcs.append(start_effect) + def add_effect(self, start_effect: StartEffect) -> None: + """Add an effect to this hook""" + self._effect_starts.append(start_effect) def set_context_provider(self, provider: ContextProviderType[Any]) -> None: self._context_providers[provider.type] = provider @@ -184,10 +165,10 @@ async def affect_component_did_render(self) -> None: async def affect_layout_did_render(self) -> None: """The layout completed a render""" - for start_effect in self._effect_funcs: - effect_info = await start_effect() - self._effect_infos.add(effect_info) - self._effect_funcs.clear() + self._effect_stops.extend( + await asyncio.gather(*[start() for start in self._effect_starts]) + ) + self._effect_starts.clear() if self._schedule_render_later: self._schedule_render() @@ -195,13 +176,12 @@ async def affect_layout_did_render(self) -> None: async def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" - for infos in self._effect_infos: - infos.stop.set() try: - await asyncio.gather(*[i.task for i in self._effect_infos]) + await asyncio.gather(*[stop() for stop in self._effect_stops]) except Exception: logger.exception("Error during effect cancellation") - self._effect_infos.clear() + finally: + self._effect_stops.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread @@ -209,7 +189,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _hook_stack.get() + hook_stack = _HOOK_STATE.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -217,7 +197,7 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _hook_stack.get().pop() is not self: + if _HOOK_STATE.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov def _schedule_render(self) -> None: @@ -227,6 +207,3 @@ def _schedule_render(self) -> None: logger.exception( f"Failed to schedule render via {self._schedule_render_callback}" ) - - -_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, EffectInfo]]" diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index ef41bb0ea..b6b761961 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -2,6 +2,7 @@ import asyncio import inspect +import sys import warnings from collections.abc import Coroutine, Sequence from logging import getLogger @@ -17,10 +18,10 @@ overload, ) -from typing_extensions import TypeAlias +from typing_extensions import Self, TypeAlias -from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT -from reactpy.core._life_cycle_hook import EffectInfo, current_hook +from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.core._life_cycle_hook import StopEffect, current_hook from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref @@ -96,7 +97,7 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: _EffectCleanFunc: TypeAlias = "Callable[[], None]" _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[asyncio.Event], Coroutine[None, None, None]]" +_AsyncEffectFunc: TypeAlias = "Callable[[Effect], Coroutine[None, None, None]]" _EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @@ -104,7 +105,6 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., - stop_timeout: float = ..., ) -> Callable[[_EffectFunc], None]: ... @@ -113,7 +113,6 @@ def use_effect( def use_effect( function: _EffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., - stop_timeout: float = ..., ) -> None: ... @@ -121,7 +120,6 @@ def use_effect( def use_effect( function: _EffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., - stop_timeout: float = REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT.current, ) -> Callable[[_EffectFunc], None] | None: """See the full :ref:`Use Effect` docs for details @@ -145,22 +143,22 @@ def use_effect( hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - effect_info: Ref[EffectInfo | None] = use_ref(None) + effect_ref: Ref[Effect | None] = use_ref(None) def add_effect(function: _EffectFunc) -> None: - effect = _cast_async_effect(function) + effect_func = _cast_async_effect(function) - async def create_effect_task() -> EffectInfo: - if effect_info.current is not None: - last_effect_info = effect_info.current - await last_effect_info.signal_stop(stop_timeout) + async def start_effect() -> StopEffect: + if effect_ref.current is not None: + await effect_ref.current.stop() - stop = asyncio.Event() - info = EffectInfo(asyncio.create_task(effect(stop)), stop) - effect_info.current = info - return info + effect = effect_ref.current = Effect() + effect.task = asyncio.create_task(effect_func(effect)) + await effect.started() - return memoize(lambda: hook.add_effect(create_effect_task)) + return effect.stop + + return memoize(lambda: hook.add_effect(start_effect)) if function is not None: add_effect(function) @@ -169,47 +167,118 @@ async def create_effect_task() -> EffectInfo: return add_effect +class Effect: + """A context manager for running asynchronous effects.""" + + task: asyncio.Task[Any] + """The task that is running the effect.""" + + def __init__(self) -> None: + self._stop = asyncio.Event() + self._started = asyncio.Event() + self._cancel_count = 0 + + async def stop(self) -> None: + """Signal the effect to stop.""" + if self._started.is_set(): + self._cancel_task() + self._stop.set() + try: + await self.task + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Error while stopping effect") + + 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 + + _3_11__aenter__ = __aenter__ + + if sys.version_info < (3, 11): # nocov + # Python<3.11 doesn't have Task.cancelling so we need to track it ourselves. + + async def __aenter__(self) -> Self: + cancel_count = 0 + old_cancel = self.task.cancel + + def new_cancel(*a, **kw) -> None: + nonlocal cancel_count + cancel_count += 1 + return old_cancel(*a, **kw) + + self.task.cancel = new_cancel + self.task.cancelling = lambda: cancel_count + + return await self._3_11__aenter__() + + async def __aexit__(self, exc_type: type[BaseException], *exc: Any) -> Any: + if exc_type is not None and not issubclass(exc_type, asyncio.CancelledError): + # propagate non-cancellation exceptions + return None + + try: + await self._stop.wait() + except asyncio.CancelledError: + if self.task.cancelling() > self._cancel_count: + # Task has been cancelled by something else - propagate it + return None + + return True + + def _cancel_task(self) -> None: + self.task.cancel() + self._cancel_count += 1 + + def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: if inspect.iscoroutinefunction(function): if len(inspect.signature(function).parameters): return function warnings.warn( - 'Async effect functions should accept a "stop" asyncio.Event as their ' + "Async effect functions should accept an Effect context manager as their " "first argument. This will be required in a future version of ReactPy.", stacklevel=3, ) - async def wrapper(stop: asyncio.Event) -> None: - task = asyncio.create_task(function()) - await stop.wait() - if not task.cancel(): + async def wrapper(effect: Effect) -> None: + cleanup = None + async with effect: try: - cleanup = await task + cleanup = await function() except Exception: logger.exception("Error while applying effect") - return - if cleanup is not None: - try: - cleanup() - except Exception: - logger.exception("Error while cleaning up effect") + if cleanup is not None: + try: + cleanup() + except Exception: + logger.exception("Error while cleaning up effect") return wrapper else: - async def wrapper(stop: asyncio.Event) -> None: - try: - cleanup = function() - except Exception: - logger.exception("Error while applying effect") - return - await stop.wait() - try: - if cleanup is not None: + async def wrapper(effect: Effect) -> None: + cleanup = None + async with effect: + try: + cleanup = function() + except Exception: + logger.exception("Error while applying effect") + + if cleanup is not None: + try: cleanup() - except Exception: - logger.exception("Error while cleaning up effect") + except Exception: + logger.exception("Error while cleaning up effect") return wrapper diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 70e22f4c9..eb80a10c6 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -302,7 +302,7 @@ def OuterComponent(): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def effect(): effect_triggered.current = True @@ -329,7 +329,7 @@ async def test_use_effect_cleanup_occurs_before_next_effect(): @reactpy.component @component_hook.capture def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) + @reactpy.use_effect(dependencies=None) def effect(): if cleanup_triggered.current: cleanup_triggered_before_next_effect.set() @@ -371,7 +371,7 @@ def ComponentWithEffect(): component_did_render.current = True - @reactpy.hooks.use_effect + @reactpy.use_effect def effect(): def cleanup(): cleanup_triggered.current = True @@ -406,7 +406,7 @@ async def test_memoized_effect_is_recreated_if_dependencies_change(): def ComponentWithMemoizedEffect(): state, set_state_callback.current = reactpy.hooks.use_state(first_value) - @reactpy.hooks.use_effect(dependencies=[state]) + @reactpy.use_effect(dependencies=[state]) def effect(): nonlocal run_count effect_ran.set() @@ -448,7 +448,7 @@ async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): def ComponentWithEffect(): state, set_state_callback.current = reactpy.hooks.use_state(first_value) - @reactpy.hooks.use_effect(dependencies=[state]) + @reactpy.use_effect(dependencies=[state]) def effect(): def cleanup(): cleanup_trigger_count.current += 1 @@ -474,19 +474,41 @@ def cleanup(): async def test_use_async_effect(): - effect_ran = WaitForEvent() + effect_started = WaitForEvent() + unblock_effect = WaitForEvent() + effect_running = WaitForEvent() @reactpy.component def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect - async def effect(): - effect_ran.set() + @reactpy.use_effect + async def effect(e): + effect_started.set() + await unblock_effect.wait() + async with e: + effect_running.set() return reactpy.html.div() async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - await effect_ran.wait() + render_task = asyncio.create_task(layout.render()) + wait_for_effect_started = asyncio.create_task(effect_started.wait()) + + done, pending = await asyncio.wait( + [wait_for_effect_started, render_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # effect should start before render completes + assert wait_for_effect_started in done + assert render_task in pending + + # effect body should only begin after render completes + assert not effect_running.is_set() + + # effect body runs eventually after render completes + unblock_effect.set() + await render_task + await effect_running.wait() async def test_use_async_effect_cleanup(): @@ -497,10 +519,11 @@ async def test_use_async_effect_cleanup(): @reactpy.component @component_hook.capture def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time - async def effect(): - effect_ran.set() - return cleanup_ran.set + @reactpy.use_effect(dependencies=None) # force this to run every time + async def effect(e): + async with e: + effect_ran.set() + cleanup_ran.set() return reactpy.html.div() @@ -518,20 +541,23 @@ async def test_use_async_effect_cancel(caplog): component_hook = HookCatcher() effect_ran = WaitForEvent() effect_was_cancelled = WaitForEvent() + effect_cleanup_ran_after_cancel = WaitForEvent() event_that_never_occurs = WaitForEvent() @reactpy.component @component_hook.capture def ComponentWithLongWaitingEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time - async def effect(): + @reactpy.use_effect(dependencies=None) # force this to run every time + async def effect(e): effect_ran.set() - try: - await event_that_never_occurs.wait() - except asyncio.CancelledError: - effect_was_cancelled.set() - raise + async with e: + try: + await event_that_never_occurs.wait() + except asyncio.CancelledError: + effect_was_cancelled.set() + raise + effect_cleanup_ran_after_cancel.set() return reactpy.html.div() @@ -544,6 +570,7 @@ async def effect(): await layout.render() await asyncio.wait_for(effect_was_cancelled.wait(), 1) + await asyncio.wait_for(effect_cleanup_ran_after_cancel.wait(), 1) # So I know we said the event never occurs but... to ensure the effect's future is # cancelled before the test is cleaned up we need to set the event. This is because @@ -555,7 +582,7 @@ async def effect(): async def test_error_in_effect_is_gracefully_handled(caplog): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def bad_effect(): msg = "Something went wong :(" raise ValueError(msg) @@ -577,7 +604,7 @@ def OuterComponent(): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def ok_effect(): def bad_cleanup(): msg = "Something went wong :(" @@ -851,7 +878,7 @@ async def test_use_effect_automatically_infers_closure_values(): def CounterWithEffect(): count, set_count.current = reactpy.hooks.use_state(0) - @reactpy.hooks.use_effect + @reactpy.use_effect def some_effect_that_uses_count(): """should automatically trigger on count change""" _ = count # use count in this closure @@ -999,7 +1026,7 @@ async def test_error_in_layout_effect_cleanup_is_gracefully_handled(): @reactpy.component @component_hook.capture def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) # always run + @reactpy.use_effect(dependencies=None) # always run def bad_effect(): def bad_cleanup(): msg = "The error message" From 986e7b03ffcaa59ac3543f9183caee402f12d41d Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 11:35:16 -0700 Subject: [PATCH 07/19] allow for cleanup task in effect for py<3.11 --- src/py/reactpy/reactpy/core/hooks.py | 44 +++++++++----- src/py/reactpy/tests/test_core/test_hooks.py | 63 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index b6b761961..b2256f619 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,10 +1,10 @@ from __future__ import annotations -import asyncio import inspect import sys import warnings -from collections.abc import Coroutine, Sequence +from asyncio import CancelledError, Event, create_task +from collections.abc import Awaitable, Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -95,9 +95,10 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: self.dispatch = dispatch +_Coro = Coroutine[None, None, _Type] _EffectCleanFunc: TypeAlias = "Callable[[], None]" _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[Effect], Coroutine[None, None, None]]" +_AsyncEffectFunc: TypeAlias = "Callable[[Effect], _Coro[Awaitable[Any] | None]]" _EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @@ -152,8 +153,7 @@ async def start_effect() -> StopEffect: if effect_ref.current is not None: await effect_ref.current.stop() - effect = effect_ref.current = Effect() - effect.task = asyncio.create_task(effect_func(effect)) + effect = effect_ref.current = Effect(effect_func) await effect.started() return effect.stop @@ -170,26 +170,37 @@ async def start_effect() -> StopEffect: class Effect: """A context manager for running asynchronous effects.""" - task: asyncio.Task[Any] - """The task that is running the effect.""" - - def __init__(self) -> None: - self._stop = asyncio.Event() - self._started = asyncio.Event() + def __init__(self, effect_func: _AsyncEffectFunc) -> None: + self.task = create_task(effect_func(self)) + self._stop = Event() + self._started = Event() + self._stopped = Event() self._cancel_count = 0 async def stop(self) -> None: """Signal the effect to stop.""" + if self._stop.is_set(): + await self._stopped.wait() + return None + if self._started.is_set(): self._cancel_task() self._stop.set() try: - await self.task - except asyncio.CancelledError: + cleanup = await self.task + except CancelledError: pass except Exception: logger.exception("Error while stopping effect") + if cleanup is not None: + try: + await cleanup + except Exception: + logger.exception("Error while cleaning up effect") + + self._stopped.set() + async def started(self) -> None: """Wait for the effect to start.""" await self._started.wait() @@ -205,6 +216,7 @@ async def __aenter__(self) -> Self: if sys.version_info < (3, 11): # nocov # Python<3.11 doesn't have Task.cancelling so we need to track it ourselves. + # Task.uncancel is a no-op since there's no way to backport the behavior. async def __aenter__(self) -> Self: cancel_count = 0 @@ -217,20 +229,22 @@ def new_cancel(*a, **kw) -> None: self.task.cancel = new_cancel self.task.cancelling = lambda: cancel_count + self.task.uncancel = lambda: None return await self._3_11__aenter__() async def __aexit__(self, exc_type: type[BaseException], *exc: Any) -> Any: - if exc_type is not None and not issubclass(exc_type, asyncio.CancelledError): + if exc_type is not None and not issubclass(exc_type, CancelledError): # propagate non-cancellation exceptions return None try: await self._stop.wait() - except asyncio.CancelledError: + except CancelledError: if self.task.cancelling() > self._cancel_count: # Task has been cancelled by something else - propagate it return None + self.task.uncancel() return True diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index eb80a10c6..c3becd07a 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -1,4 +1,5 @@ import asyncio +import sys import pytest @@ -537,6 +538,68 @@ async def effect(e): await asyncio.wait_for(cleanup_ran.wait(), 1) +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Task.uncancel does not exist", +) +async def test_use_async_effect_with_await_in_cleanup(): + component_hook = HookCatcher() + effect_ran = WaitForEvent() + cleanup_ran = WaitForEvent() + + async def cleanup_task(): + cleanup_ran.set() + + @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() + await cleanup_task() + + return reactpy.html.div() + + 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() + cleanup_ran = WaitForEvent() + + async def cleanup_task(): + cleanup_ran.set() + + @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() + return cleanup_task() + + return reactpy.html.div() + + 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_cancel(caplog): component_hook = HookCatcher() effect_ran = WaitForEvent() From d68d7be9e6abe8546ae76fbd6598eea771849729 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 11:46:58 -0700 Subject: [PATCH 08/19] add test for cleanup error + fix flaky test --- .../reactpy/reactpy/core/_life_cycle_hook.py | 2 +- src/py/reactpy/reactpy/core/hooks.py | 2 - src/py/reactpy/tests/test_core/test_hooks.py | 46 ++++++++++++++++--- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 643eed4ad..a3ba90417 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -178,7 +178,7 @@ async def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" try: await asyncio.gather(*[stop() for stop in self._effect_stops]) - except Exception: + except Exception: # nocov logger.exception("Error during effect cancellation") finally: self._effect_stops.clear() diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index b2256f619..b57fd457b 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -188,8 +188,6 @@ async def stop(self) -> None: self._stop.set() try: cleanup = await self.task - except CancelledError: - pass except Exception: logger.exception("Error while stopping effect") diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index c3becd07a..5bdee5bac 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -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") - assert (await first.get_attribute("data-value")) == "0" - assert (await second.get_attribute("data-value")) == "0" + await poll(first.get_attribute("data-value")).until_equals("0") + await poll(second.get_attribute("data-value")).until_equals("0") await button.click() - assert (await first.get_attribute("data-value")) == "1" - assert (await second.get_attribute("data-value")) == "1" + await poll(first.get_attribute("data-value")).until_equals("1") + await poll(second.get_attribute("data-value")).until_equals("1") await button.click() - assert (await first.get_attribute("data-value")) == "2" - assert (await second.get_attribute("data-value")) == "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(): @@ -600,6 +600,40 @@ async def effect(e): await asyncio.wait_for(cleanup_ran.wait(), 1) +async def test_use_async_effect_cleanup_task_error_handled_gradefully(): + component_hook = HookCatcher() + effect_ran = WaitForEvent() + cleanup_ran = WaitForEvent() + + async def cleanup_task(): + cleanup_ran.set() + raise ValueError("Something went wrong") + + @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() + return cleanup_task() + + return reactpy.html.div() + + with assert_reactpy_did_log( + match_message=r"Error while cleaning up 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_cancel(caplog): component_hook = HookCatcher() effect_ran = WaitForEvent() From e956e699b162277358a4a156d8394e3c36fceb35 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 13:04:10 -0700 Subject: [PATCH 09/19] 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() From 4ee124c3c36a97711c4fac1e6da5b91fe4e18cee Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 13:19:21 -0700 Subject: [PATCH 10/19] fix install --- pyproject.toml | 2 ++ src/py/reactpy/pyproject.toml | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 468fe26b9..fd0b28f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "semver >=2, <3", "twine", "pre-commit", + # required by some packages during install + "setuptools", ] [tool.hatch.envs.default.scripts] diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 87fa7e036..47d0c0980 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", + # required by some packages during install + "setuptools", ] [project.optional-dependencies] all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] @@ -92,8 +94,8 @@ dependencies = [ "jsonpointer", ] [tool.hatch.envs.default.scripts] -test = "playwright install && pytest {args:tests}" -test-cov = "playwright install && coverage run -m pytest {args:tests}" +test = "playwright install chromium && pytest {args:tests}" +test-cov = "playwright install chromium && coverage run -m pytest {args:tests}" cov-report = [ # "- coverage combine", "coverage report", From d5d8cc7fbd3fe9e523e2d4da281dc8f2cb6da4c0 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 14:35:30 -0700 Subject: [PATCH 11/19] rework test --- src/py/reactpy/tests/test_core/test_hooks.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 495a6a687..d59c2b423 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -594,8 +594,11 @@ async def test_effect_external_cancellation_is_propagated(): async def effect_func(e): async with e: did_start.set() - asyncio.current_task().cancel() - await asyncio.sleep(0) # allow cancellation to propagate + # We don't use e.task or asyncio.current_task() because there seems to + # be some platform dependence on whether the task is cancelled properly + outer_task.cancel() + # Allow cancellation to propagate + await asyncio.sleep(0) did_cleanup.current = True async def main(): @@ -604,7 +607,8 @@ async def main(): await effect.stop() with pytest.raises(asyncio.CancelledError): - await main() + outer_task = asyncio.create_task(main()) + await outer_task assert not did_cleanup.current From c175af64f428a79ec44d6393d340ccd523f65c37 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 15:27:57 -0700 Subject: [PATCH 12/19] fix coverage --- src/py/reactpy/reactpy/core/hooks.py | 28 +++++++++++++---- src/py/reactpy/tests/test_core/test_hooks.py | 33 +++++++++++++++----- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 03dcf41a6..e36b52226 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -3,7 +3,7 @@ import inspect import sys import warnings -from asyncio import CancelledError, Event, create_task +from asyncio import FIRST_COMPLETED, CancelledError, Event, create_task, wait from collections.abc import Awaitable, Coroutine, Sequence from logging import getLogger from types import FunctionType @@ -240,13 +240,15 @@ async def __aexit__(self, exc_type: type[BaseException], *exc: Any) -> Any: # propagate non-cancellation exceptions return None + if not self._maybe_uncancel_task(): + return None + + wait_for_stop = create_task(self._stop.wait()) try: - await self._stop.wait() + await wait([wait_for_stop, self.task], return_when=FIRST_COMPLETED) except CancelledError: - if self.task.cancelling() > self._cancel_count: - # Task has been cancelled by something else - propagate it - return None - self.task.uncancel() + if not self._maybe_uncancel_task(): + raise return True @@ -254,6 +256,20 @@ def _cancel_task(self) -> None: self.task.cancel() self._cancel_count += 1 + def _maybe_uncancel_task(self) -> bool: + """Return if task was uncancelled + + If task was not cancelled by this effect then returns. Otherwise, if the task + was cancelled at all, uncancell it and return True + """ + if self.task.cancelling() > self._cancel_count: + # Task has been cancelled by something else - propagate it + return False + elif self._cancel_count: + for _ in range(self._cancel_count): + self.task.uncancel() + return True + def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: if inspect.iscoroutinefunction(function): diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index d59c2b423..52b0e9aa3 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -587,27 +587,46 @@ async def effect_func(e): await did_cleanup.wait() -async def test_effect_external_cancellation_is_propagated(): +async def test_effect_internal_cancellation_is_propagated(): did_start = WaitForEvent() did_cleanup = Ref(False) + never_happens = asyncio.Event() async def effect_func(e): async with e: did_start.set() - # We don't use e.task or asyncio.current_task() because there seems to - # be some platform dependence on whether the task is cancelled properly - outer_task.cancel() - # Allow cancellation to propagate - await asyncio.sleep(0) + asyncio.current_task().cancel() + await never_happens.wait() did_cleanup.current = True async def main(): effect = Effect(effect_func) await did_start.wait() - await effect.stop() + await effect.task + + with pytest.raises(asyncio.CancelledError): + await main() + + assert not did_cleanup.current + + +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() + did_cleanup.current = True + + async def main(): + effect = Effect(effect_func) + await effect.task with pytest.raises(asyncio.CancelledError): outer_task = asyncio.create_task(main()) + await did_start.wait() + outer_task.cancel() await outer_task assert not did_cleanup.current From b389b2ba3f08bae10427d0ae1d162dd74a01fd8f Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 15:29:40 -0700 Subject: [PATCH 13/19] add comment --- src/py/reactpy/reactpy/core/hooks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index e36b52226..4cb8550ee 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -191,6 +191,7 @@ async def stop(self) -> None: cleanup = None if cleanup is not None: + # backwards compat for async cleanup in Python<3.11 try: await cleanup except Exception: From 3fe82fa38d09a8f58ec2ab17f3f340e0c6a49843 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 16:14:20 -0700 Subject: [PATCH 14/19] limit to 3.11 --- .github/workflows/.hatch-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index b312869e4..2b354d9c3 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -16,7 +16,7 @@ on: python-version-array: required: false type: string - default: '["3.x"]' + default: '["3.11"]' node-registry-url: required: false type: string From bb756adc7f4b47b7656772548beb54ac7b41c55c Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 18 Nov 2023 17:49:11 -0700 Subject: [PATCH 15/19] fix docs --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index a3ba90417..3a13a4ffe 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -41,12 +41,8 @@ class LifeCycleHook: .. testcode:: - from reactpy.core.hooks import ( - current_hook, - LifeCycleHook, - COMPONENT_DID_RENDER_EFFECT, - ) - + from reactpy.core._life_cycle_hooks import LifeCycleHook + from reactpy.core.hooks import current_hook, COMPONENT_DID_RENDER_EFFECT # this function will come from a layout implementation schedule_render = lambda: ... From 016b54db6aa7c1d587eca3726b88485c5ca327a4 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Tue, 21 Nov 2023 17:48:41 -0700 Subject: [PATCH 16/19] generator style effects --- docs/source/conf.py | 1 + docs/source/reference/hooks-api.rst | 47 ++- src/py/reactpy/reactpy/core/hooks.py | 262 +++++------- src/py/reactpy/tests/test_core/test_hooks.py | 416 ++++--------------- 4 files changed, 213 insertions(+), 513 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 08addad8d..0e1877b75 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -322,6 +322,7 @@ "sanic": ("https://sanic.readthedocs.io/en/latest/", None), "tornado": ("https://www.tornadoweb.org/en/stable/", None), "flask": ("https://flask.palletsprojects.com/en/1.1.x/", None), + "anyio": ("https://anyio.readthedocs.io/en/stable", None), } # -- Options for todo extension ---------------------------------------------- diff --git a/docs/source/reference/hooks-api.rst b/docs/source/reference/hooks-api.rst index ca8123e85..05b0dd18c 100644 --- a/docs/source/reference/hooks-api.rst +++ b/docs/source/reference/hooks-api.rst @@ -154,27 +154,54 @@ Async Effects ............. A behavior unique to ReactPy's implementation of ``use_effect`` is that it natively -supports ``async`` functions: +supports ``async`` effects. Async effect functions may either be an async function +or an async generator. If your effect doesn't need to do any cleanup, then you can +simply write an async function. .. code-block:: async def non_blocking_effect(): - resource = await do_something_asynchronously() - return lambda: blocking_close(resource) + await do_something() use_effect(non_blocking_effect) +However, if you need to do any cleanup, then you must ``yield False`` inside a try-finally +block and place your cleanup logic in the finally block. Yielding ``False`` indicates to +ReactPy that the effect will not yield again before it is cleaned up. -There are **three important subtleties** to note about using asynchronous effects: +.. code-block:: + + async def blocking_effect(): + await do_something() + try: + yield False + finally: + await do_cleanup() -1. The cleanup function must be a normal synchronous function. + use_effect(blocking_effect) -2. Asynchronous effects which do not complete before the next effect is created - following a re-render will be cancelled. This means an - :class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect. +If you have a long-lived effect, you may ``yield True`` multiple times. ``True`` indicates +to ReactPy that the effect will yield again if the effect doesn't need to be cleanup up +yet. + +.. code-block:: + + async def establish_connection(): + connection = await open_connection() + try: + while True: + yield False + await connection.send(create_message()) + handle_message(await connection.recv()) + finally: + await close_connection(connection) + + use_effect(non_blocking_effect) -3. An asynchronous effect may occur any time after the update which added this effect - and before the next effect following a subsequent update. +Note that, if an effect needs to be cleaned up, it will only do so when the effect +function yields control back to ReactPy. So you should ensure that either, you can +be sure that the effect will yield in a timely manner, or that you enforce a timeout +on the effect. Otherwise, ReactPy may hang while waiting for the effect to yield. Manual Effect Conditions diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 4cb8550ee..cc25701de 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,10 +1,15 @@ from __future__ import annotations import inspect -import sys import warnings -from asyncio import FIRST_COMPLETED, CancelledError, Event, create_task, wait -from collections.abc import Awaitable, Coroutine, Sequence +from asyncio import CancelledError, Event, create_task +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Coroutine, + Generator, + Sequence, +) from logging import getLogger from types import FunctionType from typing import ( @@ -18,7 +23,7 @@ overload, ) -from typing_extensions import Self, TypeAlias +from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core._life_cycle_hook import StopEffect, current_hook @@ -95,33 +100,38 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: self.dispatch = dispatch -_Coro = Coroutine[None, None, _Type] -_EffectCleanFunc: TypeAlias = "Callable[[], None]" -_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[Effect], _Coro[Awaitable[Any] | None]]" -_EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" +_SyncGeneratorEffect: TypeAlias = Callable[[], Generator[None, None, None]] +_SyncFunctionEffect: TypeAlias = Callable[[], Callable[[], None] | None] +_SyncEffect: TypeAlias = _SyncGeneratorEffect | _SyncFunctionEffect + +_AsyncGeneratorEffect: TypeAlias = Callable[[], AsyncGenerator[None, None]] +_AsyncFunctionEffect: TypeAlias = Callable[ + [], Coroutine[None, None, Callable[[], None] | None] +] +_AsyncEffect: TypeAlias = _AsyncGeneratorEffect | _AsyncFunctionEffect +_Effect: TypeAlias = _SyncEffect | _AsyncEffect @overload def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectFunc], None]: +) -> Callable[[_Effect], None]: ... @overload def use_effect( - function: _EffectFunc, + function: _Effect, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectFunc | None = None, + function: _Effect | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectFunc], None] | None: +) -> Callable[[_Effect], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -131,32 +141,54 @@ def use_effect( Dependencies for the effect. The effect will only trigger if the identity of any value in the given sequence changes (i.e. their :func:`id` is different). By default these are inferred based on local variables that are - referenced by the given function. - stop_timeout: - The maximum amount of time to wait for the effect to cleanup after it has - been signaled to stop. If the timeout is reached, an exception will be - logged and the effect will be cancelled. This does not apply to synchronous - effects. + referenced by the given function. If ``None``, then the effect runs on every + render. Returns: - If not function is provided, a decorator. Otherwise ``None``. + If no function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - effect_ref: Ref[Effect | None] = use_ref(None) + stop_last_effect: Ref[StopEffect | None] = use_ref(None) - def add_effect(function: _EffectFunc) -> None: + def add_effect(function: _Effect) -> None: effect_func = _cast_async_effect(function) - async def start_effect() -> StopEffect: - if effect_ref.current is not None: - await effect_ref.current.stop() + async def start_effect() -> Callable: + if stop_last_effect.current is not None: + await stop_last_effect.current() + + stop = Event() + + async def run_effect() -> StopEffect: + effect_gen = effect_func() + # start running the effect + effect_task = create_task(effect_gen.asend(None)) + # wait for re-render or unmount + await stop.wait() + # signal effect to stop (no-op if already complete) + effect_task.cancel() + # wait for effect to halt + try: + await effect_task + except CancelledError: + pass + # wait for effect cleanup + await effect_gen.aclose() - effect = effect_ref.current = Effect(effect_func) - await effect.started() + effect_task = create_task(run_effect()) + + async def stop_effect() -> None: + stop.set() + try: + await effect_task + except Exception: + logger.exception("Error in effect") - return effect.stop + stop_last_effect.current = stop_effect + + return stop_effect return memoize(lambda: hook.add_effect(start_effect)) @@ -167,153 +199,51 @@ async def start_effect() -> StopEffect: return add_effect -class Effect: - """A context manager for running asynchronous effects.""" - - def __init__(self, effect_func: _AsyncEffectFunc) -> None: - self.task = create_task(effect_func(self)) - self._stop = Event() - self._started = Event() - self._stopped = Event() - self._cancel_count = 0 - - async def stop(self) -> None: - """Signal the effect to stop.""" - if self._stop.is_set(): - await self._stopped.wait() - return None - - self.stop_no_wait() - try: - cleanup = await self.task - except Exception: - logger.exception("Error while stopping effect") - cleanup = None +def _cast_async_effect(function: Callable[..., Any]) -> _AsyncGeneratorEffect: + if inspect.isasyncgenfunction(function): + async_generator_effect = function + elif inspect.iscoroutinefunction(function): + async_function_effect = cast(_AsyncFunctionEffect, function) - if cleanup is not None: - # backwards compat for async cleanup in Python<3.11 + async def async_generator_effect() -> AsyncIterator[None]: + task = create_task(async_function_effect()) try: - await cleanup - except Exception: - logger.exception("Error while cleaning up effect") - - 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() - if self._stop.is_set(): - self._cancel_task() - return self - - _3_11__aenter__ = __aenter__ - - if sys.version_info < (3, 11): # nocov - # Python<3.11 doesn't have Task.cancelling so we need to track it ourselves. - # Task.uncancel is a no-op since there's no way to backport the behavior. - - async def __aenter__(self) -> Self: - cancel_count = 0 - old_cancel = self.task.cancel - - def new_cancel(*a, **kw) -> None: - nonlocal cancel_count - cancel_count += 1 - return old_cancel(*a, **kw) - - self.task.cancel = new_cancel - self.task.cancelling = lambda: cancel_count - self.task.uncancel = lambda: None - - return await self._3_11__aenter__() - - async def __aexit__(self, exc_type: type[BaseException], *exc: Any) -> Any: - if exc_type is not None and not issubclass(exc_type, CancelledError): - # propagate non-cancellation exceptions - return None - - if not self._maybe_uncancel_task(): - return None - - wait_for_stop = create_task(self._stop.wait()) - try: - await wait([wait_for_stop, self.task], return_when=FIRST_COMPLETED) - except CancelledError: - if not self._maybe_uncancel_task(): - raise - - return True - - def _cancel_task(self) -> None: - self.task.cancel() - self._cancel_count += 1 - - def _maybe_uncancel_task(self) -> bool: - """Return if task was uncancelled - - If task was not cancelled by this effect then returns. Otherwise, if the task - was cancelled at all, uncancell it and return True - """ - if self.task.cancelling() > self._cancel_count: - # Task has been cancelled by something else - propagate it - return False - elif self._cancel_count: - for _ in range(self._cancel_count): - self.task.uncancel() - return True - - -def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc: - if inspect.iscoroutinefunction(function): - if len(inspect.signature(function).parameters): - return function + yield + finally: + cleanup = await task + if cleanup is not None: + warnings.warn( + "Async effect returned a cleanup function - use an async " + "generator instead by yielding inside a try/finally block. " + "This will be an error in a future version of ReactPy.", + DeprecationWarning, + stacklevel=3, + ) + cleanup() - warnings.warn( - "Async effect functions should accept an Effect context manager as their " - "first argument. This will be required in a future version of ReactPy.", - stacklevel=3, - ) + elif inspect.isgeneratorfunction(function): + sync_generator_effect = cast(_SyncGeneratorEffect, function) - async def wrapper(effect: Effect) -> None: - cleanup = None - async with effect: - try: - cleanup = await function() - except Exception: - logger.exception("Error while applying effect") - if cleanup is not None: - try: - cleanup() - except Exception: - logger.exception("Error while cleaning up effect") + async def async_generator_effect() -> AsyncIterator[None]: + gen = sync_generator_effect() + gen.send(None) + try: + yield + finally: + gen.close() - return wrapper else: + sync_function_effect = cast(_SyncFunctionEffect, function) - async def wrapper(effect: Effect) -> None: - cleanup = None - async with effect: - try: - cleanup = function() - except Exception: - logger.exception("Error while applying effect") - - if cleanup is not None: - try: + async def async_generator_effect() -> AsyncIterator[None]: + cleanup = sync_function_effect() + try: + yield + finally: + if cleanup is not None: cleanup() - except Exception: - logger.exception("Error while cleaning up effect") - return wrapper + return async_generator_effect def use_debug_value( diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 52b0e9aa3..fd4fbde71 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -1,5 +1,4 @@ import asyncio -import sys import pytest @@ -7,7 +6,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 Effect, strictly_equal +from reactpy.core.hooks import strictly_equal, use_effect 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 @@ -474,339 +473,21 @@ def cleanup(): assert cleanup_trigger_count.current == 1 -async def test_use_async_effect(): - effect_started = WaitForEvent() - unblock_effect = WaitForEvent() - effect_running = WaitForEvent() - - @reactpy.component - def ComponentWithAsyncEffect(): - @reactpy.use_effect - async def effect(e): - effect_started.set() - await unblock_effect.wait() - async with e: - effect_running.set() - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - render_task = asyncio.create_task(layout.render()) - wait_for_effect_started = asyncio.create_task(effect_started.wait()) - - done, pending = await asyncio.wait( - [wait_for_effect_started, render_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - # effect should start before render completes - assert wait_for_effect_started in done - assert render_task in pending - - # effect body should only begin after render completes - assert not effect_running.is_set() - - # effect body runs eventually after render completes - unblock_effect.set() - await render_task - await effect_running.wait() - - -async def test_use_async_effect_cleanup(): - 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() - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - - await effect_ran.wait() - - component_hook.latest.schedule_render() - - await layout.render() - - 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_internal_cancellation_is_propagated(): - did_start = WaitForEvent() - did_cleanup = Ref(False) - never_happens = asyncio.Event() - - async def effect_func(e): - async with e: - did_start.set() - asyncio.current_task().cancel() - await never_happens.wait() - did_cleanup.current = True - - async def main(): - effect = Effect(effect_func) - await did_start.wait() - await effect.task - - with pytest.raises(asyncio.CancelledError): - await main() - - assert not did_cleanup.current - - -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() - did_cleanup.current = True - - async def main(): - effect = Effect(effect_func) - await effect.task - - with pytest.raises(asyncio.CancelledError): - outer_task = asyncio.create_task(main()) - await did_start.wait() - outer_task.cancel() - await outer_task - - assert not did_cleanup.current - - -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="asyncio.Task.uncancel does not exist", -) -async def test_use_async_effect_with_await_in_cleanup(): - component_hook = HookCatcher() - effect_ran = WaitForEvent() - cleanup_ran = WaitForEvent() - - async def cleanup_task(): - cleanup_ran.set() - - @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() - await cleanup_task() - - return reactpy.html.div() - - 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_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() - cleanup_ran = WaitForEvent() - - async def cleanup_task(): - cleanup_ran.set() - - @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() - return cleanup_task() - - return reactpy.html.div() - - 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_error_handled_gradefully(): - component_hook = HookCatcher() - effect_ran = WaitForEvent() - cleanup_ran = WaitForEvent() - - async def cleanup_task(): - cleanup_ran.set() - raise ValueError("Something went wrong") - - @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() - return cleanup_task() - - return reactpy.html.div() - - with assert_reactpy_did_log( - match_message=r"Error while cleaning up 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_cancel(caplog): component_hook = HookCatcher() effect_ran = WaitForEvent() - effect_was_cancelled = WaitForEvent() - effect_cleanup_ran_after_cancel = WaitForEvent() - - event_that_never_occurs = WaitForEvent() + effect_cleanup = WaitForEvent() @reactpy.component @component_hook.capture def ComponentWithLongWaitingEffect(): @reactpy.use_effect(dependencies=None) # force this to run every time - async def effect(e): + async def effect(): effect_ran.set() - async with e: - try: - await event_that_never_occurs.wait() - except asyncio.CancelledError: - effect_was_cancelled.set() - raise - effect_cleanup_ran_after_cancel.set() + try: + yield + finally: + effect_cleanup.set() return reactpy.html.div() @@ -818,14 +499,7 @@ async def effect(e): await layout.render() - await asyncio.wait_for(effect_was_cancelled.wait(), 1) - await asyncio.wait_for(effect_cleanup_ran_after_cancel.wait(), 1) - - # So I know we said the event never occurs but... to ensure the effect's future is - # cancelled before the test is cleaned up we need to set the event. This is because - # the cancellation doesn't propagate before the test is resolved which causes - # delayed log messages that impact other tests. - event_that_never_occurs.set() + await effect_cleanup.wait(1) async def test_error_in_effect_is_gracefully_handled(caplog): @@ -838,7 +512,7 @@ def bad_effect(): return reactpy.html.div() - with assert_reactpy_did_log(match_message=r"Error while applying effect"): + with assert_reactpy_did_log(match_message=r"Error in effect"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -864,7 +538,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Error while cleaning up effect", + match_message=r"Error in effect", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -1286,7 +960,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Error while cleaning up effect", + match_message=r"Error in effect", error_type=ValueError, match_error="The error message", ): @@ -1511,3 +1185,71 @@ def some_component(): state.current.set_value(2) await layout.render() assert state.current.value == 2 + + +async def test_slow_async_generator_effect_is_cancelled_and_cleaned_up(): + hook_catcher = HookCatcher() + + never = asyncio.Event() + did_run = WaitForEvent() + did_cancel = WaitForEvent() + did_cleanup = WaitForEvent() + + @reactpy.component + @hook_catcher.capture + def some_component(): + @use_effect(dependencies=None) + async def slow_effect(): + try: + did_run.set() + await never.wait() + yield + except asyncio.CancelledError: + did_cancel.set() + raise + finally: + # should be allowed to await in finally + await asyncio.sleep(0) + did_cleanup.set() + + async with reactpy.Layout(some_component()) as layout: + await layout.render() + + await did_run.wait() + + hook_catcher.latest.schedule_render() + render_task = asyncio.create_task(layout.render()) + + await did_cancel.wait() + await did_cleanup.wait() + + await render_task + + +async def test_sync_generator_style_effect(): + hook_catcher = HookCatcher() + + did_run = WaitForEvent() + did_cleanup = WaitForEvent() + + @reactpy.component + @hook_catcher.capture + def some_component(): + @use_effect(dependencies=None) + def sync_generator_effect(): + try: + did_run.set() + yield + finally: + did_cleanup.set() + + async with reactpy.Layout(some_component()) as layout: + await layout.render() + + await did_run.wait() + + hook_catcher.latest.schedule_render() + render_task = asyncio.create_task(layout.render()) + + await did_cleanup.wait() + await render_task From e5655d0813b2b291c16d767842ffa38318db2eba Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 25 Nov 2023 10:10:11 -0800 Subject: [PATCH 17/19] support concurrent renders --- .../reactpy/reactpy/core/_life_cycle_hook.py | 6 ++ src/py/reactpy/reactpy/core/hooks.py | 8 +- src/py/reactpy/reactpy/core/layout.py | 79 +++++++++++++------ src/py/reactpy/tests/test_client.py | 6 +- src/py/reactpy/tests/test_core/test_serve.py | 30 ++++--- 5 files changed, 90 insertions(+), 39 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 3a13a4ffe..289caee4a 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -5,6 +5,8 @@ from collections.abc import Coroutine from typing import Any, Callable, TypeVar +from anyio import Semaphore + from reactpy.core._thread_local import ThreadLocal from reactpy.core.types import ComponentType, Context, ContextProviderType @@ -93,6 +95,7 @@ class LifeCycleHook: "_effect_starts", "_effect_stops", "_is_rendering", + "_render_access", "_rendered_atleast_once", "_schedule_render_callback", "_schedule_render_later", @@ -115,6 +118,7 @@ def __init__( self._state: tuple[Any, ...] = () self._effect_starts: list[StartEffect] = [] self._effect_stops: list[StopEffect] = [] + self._render_access = Semaphore(1) # ensure only one render at a time def schedule_render(self) -> None: if self._is_rendering: @@ -147,6 +151,7 @@ def get_context_provider( async def affect_component_will_render(self, component: ComponentType) -> None: """The component is about to render""" + await self._render_access.acquire() self.component = component self._is_rendering = True self.set_current() @@ -158,6 +163,7 @@ async def affect_component_did_render(self) -> None: self._is_rendering = False self._rendered_atleast_once = True self._current_state_index = 0 + self._render_access.release() async def affect_layout_did_render(self) -> None: """The layout completed a render""" diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index cc25701de..78dcab002 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -155,16 +155,18 @@ def use_effect( def add_effect(function: _Effect) -> None: effect_func = _cast_async_effect(function) - async def start_effect() -> Callable: + async def start_effect() -> StopEffect: if stop_last_effect.current is not None: await stop_last_effect.current() stop = Event() - async def run_effect() -> StopEffect: + async def run_effect() -> None: effect_gen = effect_func() # start running the effect - effect_task = create_task(effect_gen.asend(None)) + effect_task = create_task( + cast(Coroutine[None, None, None], effect_gen.asend(None)) + ) # wait for re-render or unmount await stop.wait() # signal effect to stop (no-op if already complete) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 799b9fb14..1bd3f879f 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -1,7 +1,16 @@ from __future__ import annotations import abc -import asyncio +from asyncio import ( + FIRST_COMPLETED, + Event, + Queue, + Task, + create_task, + gather, + get_running_loop, + wait, +) from collections import Counter from collections.abc import Iterator from contextlib import AsyncExitStack @@ -41,6 +50,7 @@ class Layout: "root", "_event_handlers", "_rendering_queue", + "_render_tasks", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", ) @@ -58,6 +68,7 @@ def __init__(self, root: ComponentType) -> None: async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} + self._render_tasks: set[Task[LayoutUpdateMessage]] = set() self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) @@ -72,6 +83,7 @@ async def __aenter__(self) -> Layout: async def __aexit__(self, *exc: Any) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] + await gather(*self._render_tasks, return_exceptions=True) await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager @@ -102,21 +114,35 @@ async def deliver(self, event: LayoutEventMessage) -> None: async def render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" while True: - model_state_id = await self._rendering_queue.get() - try: - model_state = self._model_states_by_life_cycle_state_id[model_state_id] - except KeyError: - logger.debug( - "Did not render component with model state ID " - f"{model_state_id!r} - component already unmounted" - ) + render_completed = ( + create_task(wait(self._render_tasks, return_when=FIRST_COMPLETED)) + if self._render_tasks + else get_running_loop().create_future() + ) + await wait( + (create_task(self._rendering_queue.ready()), render_completed), + return_when=FIRST_COMPLETED, + ) + if render_completed.done(): + done, _ = await render_completed + update_task: Task[LayoutUpdateMessage] = done.pop() + self._render_tasks.remove(update_task) + return update_task.result() else: - update = await self._create_layout_update(model_state) - if REACTPY_CHECK_VDOM_SPEC.current: - root_id = self._root_life_cycle_state_id - root_model = self._model_states_by_life_cycle_state_id[root_id] - validate_vdom_json(root_model.model.current) - return update + model_state_id = await self._rendering_queue.get() + try: + model_state = self._model_states_by_life_cycle_state_id[ + model_state_id + ] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{model_state_id!r} - component already unmounted" + ) + else: + self._render_tasks.add( + create_task(self._create_layout_update(model_state)) + ) async def _create_layout_update( self, old_state: _ModelState @@ -127,6 +153,9 @@ async def _create_layout_update( async with AsyncExitStack() as exit_stack: await self._render_component(exit_stack, old_state, new_state, component) + if REACTPY_CHECK_VDOM_SPEC.current: + validate_vdom_json(new_state.model.current) + return { "type": "layout-update", "path": new_state.patch_path, @@ -540,6 +569,7 @@ class _ModelState: __slots__ = ( "__weakref__", "_parent_ref", + "_render_semaphore", "children_by_key", "index", "key", @@ -651,24 +681,27 @@ class _LifeCycleState(NamedTuple): class _ThreadSafeQueue(Generic[_Type]): - __slots__ = "_loop", "_queue", "_pending" - def __init__(self) -> None: - self._loop = asyncio.get_running_loop() - self._queue: asyncio.Queue[_Type] = asyncio.Queue() + self._loop = get_running_loop() + self._queue: Queue[_Type] = Queue() self._pending: set[_Type] = set() + self._ready = Event() def put(self, value: _Type) -> None: if value not in self._pending: self._pending.add(value) self._loop.call_soon_threadsafe(self._queue.put_nowait, value) + self._ready.set() + + async def ready(self) -> None: + """Return when the next value is available""" + await self._ready.wait() async def get(self) -> _Type: - while True: - value = await self._queue.get() - if value in self._pending: - break + value = await self._queue.get() self._pending.remove(value) + if not self._pending: + self._ready.clear() return value diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py index 3c7250e48..b93279f0a 100644 --- a/src/py/reactpy/tests/test_client.py +++ b/src/py/reactpy/tests/test_client.py @@ -42,7 +42,7 @@ def SomeComponent(): incr = await page.wait_for_selector("#incr") for i in range(3): - assert (await count.get_attribute("data-count")) == str(i) + await poll(count.get_attribute, "data-count").until_equals(str(i)) await incr.click() # the server is disconnected but the last view state is still shown @@ -102,7 +102,9 @@ def ButtonWithChangingColor(): for color in ["blue", "red"] * 2: await button.click() - assert (await _get_style(button))["background-color"] == color + await poll(_get_style, button).until( + lambda style, c=color: style["background-color"] == c + ) async def _get_style(element): diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 64be0ec8b..7690707f8 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -5,11 +5,13 @@ from jsonpointer import set_pointer import reactpy +from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import LayoutUpdateMessage from reactpy.testing import StaticEventHandler from tests.tooling.common import event_message +from tests.tooling.concurrency import WaitForEvent EVENT_NAME = "on_event" STATIC_EVENT_HANDLER = StaticEventHandler() @@ -96,9 +98,10 @@ async def test_dispatch(): async def test_dispatcher_handles_more_than_one_event_at_a_time(): - block_and_never_set = asyncio.Event() - will_block = asyncio.Event() - second_event_did_execute = asyncio.Event() + did_render = WaitForEvent() + block_and_never_set = WaitForEvent() + will_block = WaitForEvent() + second_event_did_execute = WaitForEvent() blocked_handler = StaticEventHandler() non_blocked_handler = StaticEventHandler() @@ -114,6 +117,10 @@ async def block_forever(): async def handle_event(): second_event_did_execute.set() + @use_effect + def set_did_render(): + did_render.set() + return reactpy.html.div( reactpy.html.button({"on_click": block_forever}), reactpy.html.button({"on_click": handle_event}), @@ -129,11 +136,12 @@ async def handle_event(): recv_queue.get, ) ) - - await recv_queue.put(event_message(blocked_handler.target)) - await will_block.wait() - - await recv_queue.put(event_message(non_blocked_handler.target)) - await second_event_did_execute.wait() - - task.cancel() + try: + await did_render.wait() + await recv_queue.put(event_message(blocked_handler.target)) + await will_block.wait() + + await recv_queue.put(event_message(non_blocked_handler.target)) + await second_event_did_execute.wait() + finally: + task.cancel() From b66d9872c579e01c792996bc457f9f343ab1786e Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 25 Nov 2023 10:16:25 -0800 Subject: [PATCH 18/19] update docs --- docs/source/reference/hooks-api.rst | 72 ++++++++++++++++------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/docs/source/reference/hooks-api.rst b/docs/source/reference/hooks-api.rst index 05b0dd18c..63162642c 100644 --- a/docs/source/reference/hooks-api.rst +++ b/docs/source/reference/hooks-api.rst @@ -89,7 +89,9 @@ Use Effect .. code-block:: - use_effect(did_render) + @use_effect + def did_render(): + ... # imperative or state mutating logic The ``use_effect`` hook accepts a function which may be imperative, or mutate state. The function will be called immediately after the layout has fully updated. @@ -117,12 +119,11 @@ then closing a connection: .. code-block:: + @use_effect def establish_connection(): connection = open_connection() return lambda: close_connection(connection) - use_effect(establish_connection) - The clean-up function will be run before the component is unmounted or, before the next effect is triggered when the component re-renders. You can :ref:`conditionally fire events ` to avoid triggering them each @@ -141,14 +142,19 @@ example, imagine that we had an effect that connected to a ``url`` state variabl url, set_url = use_state("https://example.com") + @use_effect def establish_connection(): connection = open_connection(url) return lambda: close_connection(connection) - use_effect(establish_connection) - Here, a new connection will be established whenever a new ``url`` is set. +.. warning:: + + A component will be unable to render until all its outstanding effects have been + cleaned up. As such, it's best to keep cleanup logic as simple as possible and/or + to impose a time limit. + Async Effects ............. @@ -160,48 +166,52 @@ simply write an async function. .. code-block:: - async def non_blocking_effect(): + @use_effect + async def my_async_effect(): await do_something() - use_effect(non_blocking_effect) - -However, if you need to do any cleanup, then you must ``yield False`` inside a try-finally -block and place your cleanup logic in the finally block. Yielding ``False`` indicates to -ReactPy that the effect will not yield again before it is cleaned up. +However, if you need to do any cleanup, then you'll need to write an async generator +instead. The generator should run the effect logic in a ``try`` block, ``yield`` control +back to ReactPy, and then run the cleanup logic in a ``finally`` block: .. code-block:: - async def blocking_effect(): - await do_something() + @use_effect + async def my_async_effect(): try: - yield False + await effect_logic() + yield finally: - await do_cleanup() - - use_effect(blocking_effect) + await cleanup_logic() -If you have a long-lived effect, you may ``yield True`` multiple times. ``True`` indicates -to ReactPy that the effect will yield again if the effect doesn't need to be cleanup up -yet. +When a component is re-rendered or unmounted the effect will be cancelled if it is still +running. This will typically happen for long-lived effects. One example might be an +effect that opens a connection and then responds to messages for the lifetime of the +connection: .. code-block:: - async def establish_connection(): - connection = await open_connection() + @use_effect + async def my_async_effect(): + conn = await open_connection() try: while True: - yield False - await connection.send(create_message()) - handle_message(await connection.recv()) + msg = await conn.recv() + await handle_message(msg) finally: - await close_connection(connection) + await close_connection(conn) + +.. warning:: + + Because an effect can be cancelled at any time, it's possible that the cleanup logic + will run before all of the effect logic has finished. For example, in the code + above, we exclude ``conn = await open_connection()`` from the ``try`` block because + if the effect is cancelled before the connection is opened, then we don't need to + close it. - use_effect(non_blocking_effect) +.. note:: -Note that, if an effect needs to be cleaned up, it will only do so when the effect -function yields control back to ReactPy. So you should ensure that either, you can -be sure that the effect will yield in a timely manner, or that you enforce a timeout -on the effect. Otherwise, ReactPy may hang while waiting for the effect to yield. + We don't need a yield statement here because the effect only ends when it's cancelled. Manual Effect Conditions From 8e8f8659e8c07f3b833bc3725f0fa514006e445d Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 26 Nov 2023 11:19:26 -0800 Subject: [PATCH 19/19] make concurrent renders configurable --- docs/source/about/changelog.rst | 8 ++++++ src/py/reactpy/reactpy/config.py | 8 ++++++ .../reactpy/reactpy/core/_life_cycle_hook.py | 9 +++---- src/py/reactpy/reactpy/core/layout.py | 26 ++++++++++++++++++- src/py/reactpy/tests/conftest.py | 5 +++- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 32a3df2dc..3dae9d6ae 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -28,6 +28,14 @@ Unreleased - :pull:`1118` - `module_from_template` is broken with a recent release of `requests` - :pull:`1131` - `module_from_template` did not work when using Flask backend +**Added** + +- :pull:`1093` - Better async effects (see :ref:`Async Effects`) +- :pull:`1093` - Support concurrent renders - multiple components are now able to render + simultaneously. This is a significant change to the underlying rendering logic and + should be considered experimental. You can enable this feature by setting + ``REACTPY_FEATURE_CONCURRENT_RENDER=1`` when running ReactPy. + v1.0.2 ------ diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index a2a9fbcb2..5b083b456 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -88,3 +88,11 @@ def boolean(value: str | bool | int) -> bool: validator=float, ) """The default amount of time to wait for an effect to complete""" + +REACTPY_CONCURRENT_RENDERING = Option( + "REACTPY_CONCURRENT_RENDERING", + default=False, + mutable=True, + validator=boolean, +) +"""Whether to render components concurrently. This is currently an experimental feature.""" diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 289caee4a..0c6bb380f 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -94,7 +94,6 @@ class LifeCycleHook: "_effect_funcs", "_effect_starts", "_effect_stops", - "_is_rendering", "_render_access", "_rendered_atleast_once", "_schedule_render_callback", @@ -112,7 +111,6 @@ def __init__( self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render self._schedule_render_later = False - self._is_rendering = False self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () @@ -121,7 +119,7 @@ def __init__( self._render_access = Semaphore(1) # ensure only one render at a time def schedule_render(self) -> None: - if self._is_rendering: + if self._is_rendering(): self._schedule_render_later = True else: self._schedule_render() @@ -153,14 +151,12 @@ async def affect_component_will_render(self, component: ComponentType) -> None: """The component is about to render""" await self._render_access.acquire() self.component = component - self._is_rendering = True self.set_current() async def affect_component_did_render(self) -> None: """The component completed a render""" self.unset_current() del self.component - self._is_rendering = False self._rendered_atleast_once = True self._current_state_index = 0 self._render_access.release() @@ -202,6 +198,9 @@ def unset_current(self) -> None: if _HOOK_STATE.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov + def _is_rendering(self) -> bool: + return self._render_access.value != 0 + def _schedule_render(self) -> None: try: self._schedule_render_callback() diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 1bd3f879f..a57d7157c 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -27,7 +27,11 @@ from uuid import uuid4 from weakref import ref as weakref -from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE +from reactpy.config import ( + REACTPY_CHECK_VDOM_SPEC, + REACTPY_CONCURRENT_RENDERING, + REACTPY_DEBUG_MODE, +) from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( ComponentType, @@ -112,6 +116,26 @@ async def deliver(self, event: LayoutEventMessage) -> None: ) async def render(self) -> LayoutUpdateMessage: + if REACTPY_CONCURRENT_RENDERING.current: + return await self._concurrent_render() + else: # nocov + return await self._serial_render() + + async def _serial_render(self) -> LayoutUpdateMessage: # nocov + """Await the next available render. This will block until a component is updated""" + while True: + model_state_id = await self._rendering_queue.get() + try: + model_state = self._model_states_by_life_cycle_state_id[model_state_id] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{model_state_id!r} - component already unmounted" + ) + else: + return await self._create_layout_update(model_state) + + async def _concurrent_render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" while True: render_completed = ( diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index 527d16c7a..8c1bdec75 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -8,7 +8,7 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_CONCURRENT_RENDERING, REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -27,6 +27,9 @@ def pytest_addoption(parser: Parser) -> None: ) +REACTPY_CONCURRENT_RENDERING.current = True + + @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: