Skip to content

Commit be5cf27

Browse files
committed
make LifeCycleHook private + add timeout to async effects
1 parent 53ba220 commit be5cf27

File tree

9 files changed

+97
-58
lines changed

9 files changed

+97
-58
lines changed

Diff for: src/py/reactpy/reactpy/backend/hooks.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from typing import Any
55

66
from reactpy.backend.types import Connection, Location
7-
from reactpy.core.hooks import Context, create_context, use_context
7+
from reactpy.core.hooks import create_context, use_context
8+
from reactpy.core.types import Context
89

910
# backend implementations should establish this context at the root of an app
1011
ConnectionContext: Context[Connection[Any] | None] = create_context(None)

Diff for: src/py/reactpy/reactpy/config.py

+8
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
8080
validator=float,
8181
)
8282
"""A default timeout for testing utilities in ReactPy"""
83+
84+
REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT = Option(
85+
"REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT",
86+
30.0,
87+
mutable=False,
88+
validator=float,
89+
)
90+
"""The default amount of time to wait for an effect to complete"""

Diff for: src/py/reactpy/reactpy/core/_life_cycle_hook.py

+20-39
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@
44
import logging
55
from collections.abc import Coroutine
66
from dataclasses import dataclass
7-
from typing import Any, Callable, Generic, Protocol, TypeVar
7+
from typing import Any, Callable, TypeVar
88
from weakref import WeakSet
99

1010
from typing_extensions import TypeAlias
1111

1212
from reactpy.core._thread_local import ThreadLocal
13-
from reactpy.core.types import ComponentType, Key, VdomDict
13+
from reactpy.core.types import ComponentType, Context, ContextProviderType
1414

1515
T = TypeVar("T")
16-
1716
logger = logging.getLogger(__name__)
1817

1918

@@ -29,44 +28,24 @@ def current_hook() -> LifeCycleHook:
2928
_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
3029

3130

32-
class Context(Protocol[T]):
33-
"""Returns a :class:`ContextProvider` component"""
34-
35-
def __call__(
36-
self,
37-
*children: Any,
38-
value: T = ...,
39-
key: Key | None = ...,
40-
) -> ContextProvider[T]:
41-
...
42-
43-
44-
class ContextProvider(Generic[T]):
45-
def __init__(
46-
self,
47-
*children: Any,
48-
value: T,
49-
key: Key | None,
50-
type: Context[T],
51-
) -> None:
52-
self.children = children
53-
self.key = key
54-
self.type = type
55-
self._value = value
56-
57-
def render(self) -> VdomDict:
58-
current_hook().set_context_provider(self)
59-
return {"tagName": "", "children": self.children}
60-
61-
def __repr__(self) -> str:
62-
return f"{type(self).__name__}({self.type})"
63-
64-
6531
@dataclass(frozen=True)
6632
class EffectInfo:
6733
task: asyncio.Task[None]
6834
stop: asyncio.Event
6935

36+
async def signal_stop(self, timeout: float) -> None:
37+
"""Signal the effect to stop and wait for it to complete."""
38+
self.stop.set()
39+
try:
40+
await asyncio.wait_for(self.task, timeout=timeout)
41+
finally:
42+
# a no-op if the task has already completed
43+
if self.task.cancel():
44+
try:
45+
await self.task
46+
except asyncio.CancelledError:
47+
logger.exception("Effect failed to stop after %s seconds", timeout)
48+
7049

7150
class LifeCycleHook:
7251
"""Defines the life cycle of a layout component.
@@ -150,7 +129,7 @@ def __init__(
150129
self,
151130
schedule_render: Callable[[], None],
152131
) -> None:
153-
self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
132+
self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
154133
self._schedule_render_callback = schedule_render
155134
self._schedule_render_later = False
156135
self._is_rendering = False
@@ -181,10 +160,12 @@ def add_effect(self, start_effect: _EffectStarter) -> None:
181160
"""Trigger a function on the occurrence of the given effect type"""
182161
self._effect_funcs.append(start_effect)
183162

184-
def set_context_provider(self, provider: ContextProvider[Any]) -> None:
163+
def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
185164
self._context_providers[provider.type] = provider
186165

187-
def get_context_provider(self, context: Context[T]) -> ContextProvider[T] | None:
166+
def get_context_provider(
167+
self, context: Context[T]
168+
) -> ContextProviderType[T] | None:
188169
return self._context_providers.get(context)
189170

190171
async def affect_component_will_render(self, component: ComponentType) -> None:

Diff for: src/py/reactpy/reactpy/core/hooks.py

+38-14
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,9 @@
1919

2020
from typing_extensions import TypeAlias
2121

22-
from reactpy.config import REACTPY_DEBUG_MODE
23-
from reactpy.core._life_cycle_hook import (
24-
Context,
25-
ContextProvider,
26-
EffectInfo,
27-
current_hook,
28-
)
29-
from reactpy.core.types import Key, State
22+
from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT
23+
from reactpy.core._life_cycle_hook import EffectInfo, current_hook
24+
from reactpy.core.types import Context, Key, State, VdomDict
3025
from reactpy.utils import Ref
3126

3227
if not TYPE_CHECKING:
@@ -109,6 +104,7 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
109104
def use_effect(
110105
function: None = None,
111106
dependencies: Sequence[Any] | ellipsis | None = ...,
107+
stop_timeout: float = ...,
112108
) -> Callable[[_EffectFunc], None]:
113109
...
114110

@@ -117,13 +113,15 @@ def use_effect(
117113
def use_effect(
118114
function: _EffectFunc,
119115
dependencies: Sequence[Any] | ellipsis | None = ...,
116+
stop_timeout: float = ...,
120117
) -> None:
121118
...
122119

123120

124121
def use_effect(
125122
function: _EffectFunc | None = None,
126123
dependencies: Sequence[Any] | ellipsis | None = ...,
124+
stop_timeout: float = REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT.current,
127125
) -> Callable[[_EffectFunc], None] | None:
128126
"""See the full :ref:`Use Effect` docs for details
129127
@@ -135,6 +133,11 @@ def use_effect(
135133
of any value in the given sequence changes (i.e. their :func:`id` is
136134
different). By default these are inferred based on local variables that are
137135
referenced by the given function.
136+
stop_timeout:
137+
The maximum amount of time to wait for the effect to cleanup after it has
138+
been signaled to stop. If the timeout is reached, an exception will be
139+
logged and the effect will be cancelled. This does not apply to synchronous
140+
effects.
138141
139142
Returns:
140143
If not function is provided, a decorator. Otherwise ``None``.
@@ -150,8 +153,7 @@ def add_effect(function: _EffectFunc) -> None:
150153
async def create_effect_task() -> EffectInfo:
151154
if effect_info.current is not None:
152155
last_effect_info = effect_info.current
153-
last_effect_info.stop.set()
154-
await last_effect_info.task
156+
await last_effect_info.signal_stop(stop_timeout)
155157

156158
stop = asyncio.Event()
157159
info = EffectInfo(asyncio.create_task(effect(stop)), stop)
@@ -173,7 +175,8 @@ def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc:
173175
return function
174176

175177
warnings.warn(
176-
'Async effect functions should accept a "stop" asyncio.Event as their first argument',
178+
'Async effect functions should accept a "stop" asyncio.Event as their '
179+
"first argument. This will be required in a future version of ReactPy.",
177180
stacklevel=3,
178181
)
179182

@@ -249,8 +252,8 @@ def context(
249252
*children: Any,
250253
value: _Type = default_value,
251254
key: Key | None = None,
252-
) -> ContextProvider[_Type]:
253-
return ContextProvider(
255+
) -> _ContextProvider[_Type]:
256+
return _ContextProvider(
254257
*children,
255258
value=value,
256259
key=key,
@@ -280,7 +283,28 @@ def use_context(context: Context[_Type]) -> _Type:
280283
raise TypeError(f"{context} has no 'value' kwarg") # nocov
281284
return cast(_Type, context.__kwdefaults__["value"])
282285

283-
return provider._value
286+
return provider.value
287+
288+
289+
class _ContextProvider(Generic[_Type]):
290+
def __init__(
291+
self,
292+
*children: Any,
293+
value: _Type,
294+
key: Key | None,
295+
type: Context[_Type],
296+
) -> None:
297+
self.children = children
298+
self.key = key
299+
self.type = type
300+
self.value = value
301+
302+
def render(self) -> VdomDict:
303+
current_hook().set_context_provider(self)
304+
return {"tagName": "", "children": self.children}
305+
306+
def __repr__(self) -> str:
307+
return f"{type(self).__name__}({self.type})"
284308

285309

286310
_ActionType = TypeVar("_ActionType")

Diff for: src/py/reactpy/reactpy/core/layout.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from weakref import ref as weakref
2020

2121
from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE
22-
from reactpy.core.hooks import LifeCycleHook
22+
from reactpy.core._life_cycle_hook import LifeCycleHook
2323
from reactpy.core.types import (
2424
ComponentType,
2525
EventHandlerDict,

Diff for: src/py/reactpy/reactpy/core/types.py

+24
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from typing_extensions import TypeAlias, TypedDict
2121

2222
_Type = TypeVar("_Type")
23+
_Type_invariant = TypeVar("_Type_invariant", covariant=False)
2324

2425

2526
if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11):
@@ -233,3 +234,26 @@ class LayoutEventMessage(TypedDict):
233234
"""The ID of the event handler."""
234235
data: Sequence[Any]
235236
"""A list of event data passed to the event handler."""
237+
238+
239+
class Context(Protocol[_Type_invariant]):
240+
"""Returns a :class:`ContextProvider` component"""
241+
242+
def __call__(
243+
self,
244+
*children: Any,
245+
value: _Type_invariant = ...,
246+
key: Key | None = ...,
247+
) -> ContextProviderType[_Type_invariant]:
248+
...
249+
250+
251+
class ContextProviderType(ComponentType, Protocol[_Type]):
252+
"""A component which provides a context value to its children"""
253+
254+
type: Context[_Type]
255+
"""The context type"""
256+
257+
@property
258+
def value(self) -> _Type:
259+
"Current context value"

Diff for: src/py/reactpy/reactpy/testing/common.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
from typing_extensions import ParamSpec
1414

1515
from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR
16+
from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook
1617
from reactpy.core.events import EventHandler, to_event_handler_function
17-
from reactpy.core.hooks import LifeCycleHook, current_hook
1818

1919

2020
def clear_reactpy_web_modules_dir() -> None:

Diff for: src/py/reactpy/reactpy/types.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
from reactpy.backend.types import BackendImplementation, Connection, Location
88
from reactpy.core.component import Component
9-
from reactpy.core.hooks import Context
109
from reactpy.core.types import (
1110
ComponentConstructor,
1211
ComponentType,
12+
Context,
1313
EventHandlerDict,
1414
EventHandlerFunc,
1515
EventHandlerMapping,

Diff for: src/py/reactpy/tests/test_core/test_hooks.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import reactpy
66
from reactpy import html
77
from reactpy.config import REACTPY_DEBUG_MODE
8-
from reactpy.core.hooks import LifeCycleHook, strictly_equal
8+
from reactpy.core._life_cycle_hook import LifeCycleHook
9+
from reactpy.core.hooks import strictly_equal
910
from reactpy.core.layout import Layout
1011
from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll
1112
from reactpy.testing.logs import assert_reactpy_did_not_log

0 commit comments

Comments
 (0)