Skip to content

Commit 3d81311

Browse files
committed
async effect context
1 parent 731fc9b commit 3d81311

File tree

4 files changed

+188
-114
lines changed

4 files changed

+188
-114
lines changed

Diff for: pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ ignore = [
130130
"PLR0915",
131131
]
132132
unfixable = [
133-
# Don't touch unused imports
133+
# Don't touch unused imports or unused variables
134134
"F401",
135+
"F841",
135136
]
136137

137138
[tool.ruff.isort]

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

+23-46
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,30 @@
33
import asyncio
44
import logging
55
from collections.abc import Coroutine
6-
from dataclasses import dataclass
76
from typing import Any, Callable, TypeVar
8-
from weakref import WeakSet
9-
10-
from typing_extensions import TypeAlias
117

128
from reactpy.core._thread_local import ThreadLocal
139
from reactpy.core.types import ComponentType, Context, ContextProviderType
1410

1511
T = TypeVar("T")
12+
13+
StopEffect = Callable[[], Coroutine[None, None, None]]
14+
StartEffect = Callable[[], Coroutine[None, None, StopEffect]]
15+
1616
logger = logging.getLogger(__name__)
1717

18+
_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
19+
1820

1921
def current_hook() -> LifeCycleHook:
2022
"""Get the current :class:`LifeCycleHook`"""
21-
hook_stack = _hook_stack.get()
23+
hook_stack = _HOOK_STATE.get()
2224
if not hook_stack:
2325
msg = "No life cycle hook is active. Are you rendering in a layout?"
2426
raise RuntimeError(msg)
2527
return hook_stack[-1]
2628

2729

28-
_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
29-
30-
31-
@dataclass(frozen=True)
32-
class EffectInfo:
33-
task: asyncio.Task[None]
34-
stop: asyncio.Event
35-
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-
49-
5030
class LifeCycleHook:
5131
"""Defines the life cycle of a layout component.
5232
@@ -114,7 +94,8 @@ class LifeCycleHook:
11494
"_context_providers",
11595
"_current_state_index",
11696
"_effect_funcs",
117-
"_effect_infos",
97+
"_effect_starts",
98+
"_effect_stops",
11899
"_is_rendering",
119100
"_rendered_atleast_once",
120101
"_schedule_render_callback",
@@ -136,8 +117,8 @@ def __init__(
136117
self._rendered_atleast_once = False
137118
self._current_state_index = 0
138119
self._state: tuple[Any, ...] = ()
139-
self._effect_funcs: list[_EffectStarter] = []
140-
self._effect_infos: WeakSet[EffectInfo] = WeakSet()
120+
self._effect_starts: list[StartEffect] = []
121+
self._effect_stops: list[StopEffect] = []
141122

142123
def schedule_render(self) -> None:
143124
if self._is_rendering:
@@ -156,9 +137,9 @@ def use_state(self, function: Callable[[], T]) -> T:
156137
self._current_state_index += 1
157138
return result
158139

159-
def add_effect(self, start_effect: _EffectStarter) -> None:
160-
"""Trigger a function on the occurrence of the given effect type"""
161-
self._effect_funcs.append(start_effect)
140+
def add_effect(self, start_effect: StartEffect) -> None:
141+
"""Add an effect to this hook"""
142+
self._effect_starts.append(start_effect)
162143

163144
def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
164145
self._context_providers[provider.type] = provider
@@ -184,40 +165,39 @@ async def affect_component_did_render(self) -> None:
184165

185166
async def affect_layout_did_render(self) -> None:
186167
"""The layout completed a render"""
187-
for start_effect in self._effect_funcs:
188-
effect_info = await start_effect()
189-
self._effect_infos.add(effect_info)
190-
self._effect_funcs.clear()
168+
self._effect_stops.extend(
169+
await asyncio.gather(*[start() for start in self._effect_starts])
170+
)
171+
self._effect_starts.clear()
191172

192173
if self._schedule_render_later:
193174
self._schedule_render()
194175
self._schedule_render_later = False
195176

196177
async def affect_component_will_unmount(self) -> None:
197178
"""The component is about to be removed from the layout"""
198-
for infos in self._effect_infos:
199-
infos.stop.set()
200179
try:
201-
await asyncio.gather(*[i.task for i in self._effect_infos])
180+
await asyncio.gather(*[stop() for stop in self._effect_stops])
202181
except Exception:
203182
logger.exception("Error during effect cancellation")
204-
self._effect_infos.clear()
183+
finally:
184+
self._effect_stops.clear()
205185

206186
def set_current(self) -> None:
207187
"""Set this hook as the active hook in this thread
208188
209189
This method is called by a layout before entering the render method
210190
of this hook's associated component.
211191
"""
212-
hook_stack = _hook_stack.get()
192+
hook_stack = _HOOK_STATE.get()
213193
if hook_stack:
214194
parent = hook_stack[-1]
215195
self._context_providers.update(parent._context_providers)
216196
hook_stack.append(self)
217197

218198
def unset_current(self) -> None:
219199
"""Unset this hook as the active hook in this thread"""
220-
if _hook_stack.get().pop() is not self:
200+
if _HOOK_STATE.get().pop() is not self:
221201
raise RuntimeError("Hook stack is in an invalid state") # nocov
222202

223203
def _schedule_render(self) -> None:
@@ -227,6 +207,3 @@ def _schedule_render(self) -> None:
227207
logger.exception(
228208
f"Failed to schedule render via {self._schedule_render_callback}"
229209
)
230-
231-
232-
_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, EffectInfo]]"

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

+110-41
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import inspect
5+
import sys
56
import warnings
67
from collections.abc import Coroutine, Sequence
78
from logging import getLogger
@@ -17,10 +18,10 @@
1718
overload,
1819
)
1920

20-
from typing_extensions import TypeAlias
21+
from typing_extensions import Self, TypeAlias
2122

22-
from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT
23-
from reactpy.core._life_cycle_hook import EffectInfo, current_hook
23+
from reactpy.config import REACTPY_DEBUG_MODE
24+
from reactpy.core._life_cycle_hook import StopEffect, current_hook
2425
from reactpy.core.types import Context, Key, State, VdomDict
2526
from reactpy.utils import Ref
2627

@@ -96,15 +97,14 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
9697

9798
_EffectCleanFunc: TypeAlias = "Callable[[], None]"
9899
_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
99-
_AsyncEffectFunc: TypeAlias = "Callable[[asyncio.Event], Coroutine[None, None, None]]"
100+
_AsyncEffectFunc: TypeAlias = "Callable[[Effect], Coroutine[None, None, None]]"
100101
_EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
101102

102103

103104
@overload
104105
def use_effect(
105106
function: None = None,
106107
dependencies: Sequence[Any] | ellipsis | None = ...,
107-
stop_timeout: float = ...,
108108
) -> Callable[[_EffectFunc], None]:
109109
...
110110

@@ -113,15 +113,13 @@ def use_effect(
113113
def use_effect(
114114
function: _EffectFunc,
115115
dependencies: Sequence[Any] | ellipsis | None = ...,
116-
stop_timeout: float = ...,
117116
) -> None:
118117
...
119118

120119

121120
def use_effect(
122121
function: _EffectFunc | None = None,
123122
dependencies: Sequence[Any] | ellipsis | None = ...,
124-
stop_timeout: float = REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT.current,
125123
) -> Callable[[_EffectFunc], None] | None:
126124
"""See the full :ref:`Use Effect` docs for details
127125
@@ -145,22 +143,22 @@ def use_effect(
145143
hook = current_hook()
146144
dependencies = _try_to_infer_closure_values(function, dependencies)
147145
memoize = use_memo(dependencies=dependencies)
148-
effect_info: Ref[EffectInfo | None] = use_ref(None)
146+
effect_ref: Ref[Effect | None] = use_ref(None)
149147

150148
def add_effect(function: _EffectFunc) -> None:
151-
effect = _cast_async_effect(function)
149+
effect_func = _cast_async_effect(function)
152150

153-
async def create_effect_task() -> EffectInfo:
154-
if effect_info.current is not None:
155-
last_effect_info = effect_info.current
156-
await last_effect_info.signal_stop(stop_timeout)
151+
async def start_effect() -> StopEffect:
152+
if effect_ref.current is not None:
153+
await effect_ref.current.stop()
157154

158-
stop = asyncio.Event()
159-
info = EffectInfo(asyncio.create_task(effect(stop)), stop)
160-
effect_info.current = info
161-
return info
155+
effect = effect_ref.current = Effect()
156+
effect.task = asyncio.create_task(effect_func(effect))
157+
await effect.started()
162158

163-
return memoize(lambda: hook.add_effect(create_effect_task))
159+
return effect.stop
160+
161+
return memoize(lambda: hook.add_effect(start_effect))
164162

165163
if function is not None:
166164
add_effect(function)
@@ -169,47 +167,118 @@ async def create_effect_task() -> EffectInfo:
169167
return add_effect
170168

171169

170+
class Effect:
171+
"""A context manager for running asynchronous effects."""
172+
173+
task: asyncio.Task[Any]
174+
"""The task that is running the effect."""
175+
176+
def __init__(self) -> None:
177+
self._stop = asyncio.Event()
178+
self._started = asyncio.Event()
179+
self._cancel_count = 0
180+
181+
async def stop(self) -> None:
182+
"""Signal the effect to stop."""
183+
if self._started.is_set():
184+
self._cancel_task()
185+
self._stop.set()
186+
try:
187+
await self.task
188+
except asyncio.CancelledError:
189+
pass
190+
except Exception:
191+
logger.exception("Error while stopping effect")
192+
193+
async def started(self) -> None:
194+
"""Wait for the effect to start."""
195+
await self._started.wait()
196+
197+
async def __aenter__(self) -> Self:
198+
self._started.set()
199+
self._cancel_count = self.task.cancelling()
200+
if self._stop.is_set():
201+
self._cancel_task()
202+
return self
203+
204+
_3_11__aenter__ = __aenter__
205+
206+
if sys.version_info < (3, 11): # nocov
207+
# Python<3.11 doesn't have Task.cancelling so we need to track it ourselves.
208+
209+
async def __aenter__(self) -> Self:
210+
cancel_count = 0
211+
old_cancel = self.task.cancel
212+
213+
def new_cancel(*a, **kw) -> None:
214+
nonlocal cancel_count
215+
cancel_count += 1
216+
return old_cancel(*a, **kw)
217+
218+
self.task.cancel = new_cancel
219+
self.task.cancelling = lambda: cancel_count
220+
221+
return await self._3_11__aenter__()
222+
223+
async def __aexit__(self, exc_type: type[BaseException], *exc: Any) -> Any:
224+
if exc_type is not None and not issubclass(exc_type, asyncio.CancelledError):
225+
# propagate non-cancellation exceptions
226+
return None
227+
228+
try:
229+
await self._stop.wait()
230+
except asyncio.CancelledError:
231+
if self.task.cancelling() > self._cancel_count:
232+
# Task has been cancelled by something else - propagate it
233+
return None
234+
235+
return True
236+
237+
def _cancel_task(self) -> None:
238+
self.task.cancel()
239+
self._cancel_count += 1
240+
241+
172242
def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc:
173243
if inspect.iscoroutinefunction(function):
174244
if len(inspect.signature(function).parameters):
175245
return function
176246

177247
warnings.warn(
178-
'Async effect functions should accept a "stop" asyncio.Event as their '
248+
"Async effect functions should accept an Effect context manager as their "
179249
"first argument. This will be required in a future version of ReactPy.",
180250
stacklevel=3,
181251
)
182252

183-
async def wrapper(stop: asyncio.Event) -> None:
184-
task = asyncio.create_task(function())
185-
await stop.wait()
186-
if not task.cancel():
253+
async def wrapper(effect: Effect) -> None:
254+
cleanup = None
255+
async with effect:
187256
try:
188-
cleanup = await task
257+
cleanup = await function()
189258
except Exception:
190259
logger.exception("Error while applying effect")
191-
return
192-
if cleanup is not None:
193-
try:
194-
cleanup()
195-
except Exception:
196-
logger.exception("Error while cleaning up effect")
260+
if cleanup is not None:
261+
try:
262+
cleanup()
263+
except Exception:
264+
logger.exception("Error while cleaning up effect")
197265

198266
return wrapper
199267
else:
200268

201-
async def wrapper(stop: asyncio.Event) -> None:
202-
try:
203-
cleanup = function()
204-
except Exception:
205-
logger.exception("Error while applying effect")
206-
return
207-
await stop.wait()
208-
try:
209-
if cleanup is not None:
269+
async def wrapper(effect: Effect) -> None:
270+
cleanup = None
271+
async with effect:
272+
try:
273+
cleanup = function()
274+
except Exception:
275+
logger.exception("Error while applying effect")
276+
277+
if cleanup is not None:
278+
try:
210279
cleanup()
211-
except Exception:
212-
logger.exception("Error while cleaning up effect")
280+
except Exception:
281+
logger.exception("Error while cleaning up effect")
213282

214283
return wrapper
215284

0 commit comments

Comments
 (0)