2
2
3
3
import asyncio
4
4
import inspect
5
+ import sys
5
6
import warnings
6
7
from collections .abc import Coroutine , Sequence
7
8
from logging import getLogger
17
18
overload ,
18
19
)
19
20
20
- from typing_extensions import TypeAlias
21
+ from typing_extensions import Self , TypeAlias
21
22
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
24
25
from reactpy .core .types import Context , Key , State , VdomDict
25
26
from reactpy .utils import Ref
26
27
@@ -96,15 +97,14 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
96
97
97
98
_EffectCleanFunc : TypeAlias = "Callable[[], None]"
98
99
_SyncEffectFunc : TypeAlias = "Callable[[], _EffectCleanFunc | None]"
99
- _AsyncEffectFunc : TypeAlias = "Callable[[asyncio.Event ], Coroutine[None, None, None]]"
100
+ _AsyncEffectFunc : TypeAlias = "Callable[[Effect ], Coroutine[None, None, None]]"
100
101
_EffectFunc : TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
101
102
102
103
103
104
@overload
104
105
def use_effect (
105
106
function : None = None ,
106
107
dependencies : Sequence [Any ] | ellipsis | None = ...,
107
- stop_timeout : float = ...,
108
108
) -> Callable [[_EffectFunc ], None ]:
109
109
...
110
110
@@ -113,15 +113,13 @@ def use_effect(
113
113
def use_effect (
114
114
function : _EffectFunc ,
115
115
dependencies : Sequence [Any ] | ellipsis | None = ...,
116
- stop_timeout : float = ...,
117
116
) -> None :
118
117
...
119
118
120
119
121
120
def use_effect (
122
121
function : _EffectFunc | None = None ,
123
122
dependencies : Sequence [Any ] | ellipsis | None = ...,
124
- stop_timeout : float = REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT .current ,
125
123
) -> Callable [[_EffectFunc ], None ] | None :
126
124
"""See the full :ref:`Use Effect` docs for details
127
125
@@ -145,22 +143,22 @@ def use_effect(
145
143
hook = current_hook ()
146
144
dependencies = _try_to_infer_closure_values (function , dependencies )
147
145
memoize = use_memo (dependencies = dependencies )
148
- effect_info : Ref [EffectInfo | None ] = use_ref (None )
146
+ effect_ref : Ref [Effect | None ] = use_ref (None )
149
147
150
148
def add_effect (function : _EffectFunc ) -> None :
151
- effect = _cast_async_effect (function )
149
+ effect_func = _cast_async_effect (function )
152
150
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 ()
157
154
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 ()
162
158
163
- return memoize (lambda : hook .add_effect (create_effect_task ))
159
+ return effect .stop
160
+
161
+ return memoize (lambda : hook .add_effect (start_effect ))
164
162
165
163
if function is not None :
166
164
add_effect (function )
@@ -169,47 +167,118 @@ async def create_effect_task() -> EffectInfo:
169
167
return add_effect
170
168
171
169
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
+
172
242
def _cast_async_effect (function : Callable [..., Any ]) -> _AsyncEffectFunc :
173
243
if inspect .iscoroutinefunction (function ):
174
244
if len (inspect .signature (function ).parameters ):
175
245
return function
176
246
177
247
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 "
179
249
"first argument. This will be required in a future version of ReactPy." ,
180
250
stacklevel = 3 ,
181
251
)
182
252
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 :
187
256
try :
188
- cleanup = await task
257
+ cleanup = await function ()
189
258
except Exception :
190
259
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" )
197
265
198
266
return wrapper
199
267
else :
200
268
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 :
210
279
cleanup ()
211
- except Exception :
212
- logger .exception ("Error while cleaning up effect" )
280
+ except Exception :
281
+ logger .exception ("Error while cleaning up effect" )
213
282
214
283
return wrapper
215
284
0 commit comments