Skip to content

Commit

Permalink
make LifeCycleHook private + add timeout to async effects
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed Jul 15, 2023
1 parent 53ba220 commit f3d4405
Show file tree
Hide file tree
Showing 8 changed files with 95 additions and 57 deletions.
3 changes: 2 additions & 1 deletion src/py/reactpy/reactpy/backend/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/py/reactpy/reactpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
59 changes: 20 additions & 39 deletions src/py/reactpy/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
52 changes: 38 additions & 14 deletions src/py/reactpy/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
...

Expand All @@ -117,13 +113,15 @@ def use_effect(
def use_effect(
function: _EffectFunc,
dependencies: Sequence[Any] | ellipsis | None = ...,
stop_timeout: float = ...,
) -> None:
...


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
Expand All @@ -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``.
Expand All @@ -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)
Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/py/reactpy/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/py/reactpy/reactpy/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion src/py/reactpy/reactpy/testing/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/py/reactpy/reactpy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit f3d4405

Please sign in to comment.