22
33import asyncio
44import inspect
5+ import sys
56import warnings
67from collections .abc import Coroutine , Sequence
78from logging import getLogger
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
2425from reactpy .core .types import Context , Key , State , VdomDict
2526from 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
104105def 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(
113113def use_effect (
114114 function : _EffectFunc ,
115115 dependencies : Sequence [Any ] | ellipsis | None = ...,
116- stop_timeout : float = ...,
117116) -> None :
118117 ...
119118
120119
121120def 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+
172242def _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