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..b87976c0e 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"{type(self).__name__}({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,