Skip to content

Commit 1a221c8

Browse files
authored
Better async effect shutdown behavior (#1267)
1 parent 1069928 commit 1a221c8

File tree

4 files changed

+77
-46
lines changed

4 files changed

+77
-46
lines changed

docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Unreleased
2222
- :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``).
2323
- :pull:`1113` - Added support for Python 3.12 and 3.13.
2424
- :pull:`1264` - Added ``reactpy.use_async_effect`` hook.
25+
- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook.
2526

2627
**Changed**
2728

pyproject.toml

+2-4
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,13 @@ testing = ["playwright"]
9393
[tool.hatch.envs.hatch-test]
9494
extra-dependencies = [
9595
"pytest-sugar",
96-
"pytest-asyncio>=0.23",
97-
"pytest-timeout",
98-
"coverage[toml]>=6.5",
96+
"pytest-asyncio",
9997
"responses",
10098
"playwright",
10199
"jsonpointer",
102100
"uvicorn[standard]",
103101
"jinja2-simple-tags",
104-
"jinja2 >=3",
102+
"jinja2",
105103
"starlette",
106104
]
107105

src/reactpy/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from reactpy.core.events import event
88
from reactpy.core.hooks import (
99
create_context,
10+
use_async_effect,
1011
use_callback,
1112
use_connection,
1213
use_context,
@@ -24,7 +25,7 @@
2425
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
2526

2627
__author__ = "The Reactive Python Team"
27-
__version__ = "2.0.0a0"
28+
__version__ = "2.0.0a1"
2829

2930
__all__ = [
3031
"Layout",
@@ -41,6 +42,7 @@
4142
"html_to_vdom",
4243
"logging",
4344
"types",
45+
"use_async_effect",
4446
"use_callback",
4547
"use_connection",
4648
"use_context",

src/reactpy/core/hooks.py

+71-41
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131

3232
__all__ = [
33+
"use_async_effect",
3334
"use_callback",
3435
"use_effect",
3536
"use_memo",
@@ -119,7 +120,12 @@ def use_effect(
119120
function: _SyncEffectFunc | None = None,
120121
dependencies: Sequence[Any] | ellipsis | None = ...,
121122
) -> Callable[[_SyncEffectFunc], None] | None:
122-
"""See the full :ref:`Use Effect` docs for details
123+
"""
124+
A hook that manages an synchronous side effect in a React-like component.
125+
126+
This hook allows you to run a synchronous function as a side effect and
127+
ensures that the effect is properly cleaned up when the component is
128+
re-rendered or unmounted.
123129
124130
Parameters:
125131
function:
@@ -136,96 +142,114 @@ def use_effect(
136142
hook = current_hook()
137143
dependencies = _try_to_infer_closure_values(function, dependencies)
138144
memoize = use_memo(dependencies=dependencies)
139-
last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None)
145+
cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None)
140146

141-
def add_effect(function: _SyncEffectFunc) -> None:
147+
def decorator(func: _SyncEffectFunc) -> None:
142148
async def effect(stop: asyncio.Event) -> None:
143-
if last_clean_callback.current is not None:
144-
last_clean_callback.current()
145-
last_clean_callback.current = None
146-
clean = last_clean_callback.current = function()
149+
# Since the effect is asynchronous, we need to make sure we
150+
# always clean up the previous effect's resources
151+
run_effect_cleanup(cleanup_func)
152+
153+
# Execute the effect and store the clean-up function
154+
cleanup_func.current = func()
155+
156+
# Wait until we get the signal to stop this effect
147157
await stop.wait()
148-
if clean is not None:
149-
clean()
158+
159+
# Run the clean-up function when the effect is stopped,
160+
# if it hasn't been run already by a new effect
161+
run_effect_cleanup(cleanup_func)
150162

151163
return memoize(lambda: hook.add_effect(effect))
152164

153-
if function is not None:
154-
add_effect(function)
165+
# Handle decorator usage
166+
if function:
167+
decorator(function)
155168
return None
156-
157-
return add_effect
169+
return decorator
158170

159171

160172
@overload
161173
def use_async_effect(
162174
function: None = None,
163175
dependencies: Sequence[Any] | ellipsis | None = ...,
176+
shutdown_timeout: float = 0.1,
164177
) -> Callable[[_EffectApplyFunc], None]: ...
165178

166179

167180
@overload
168181
def use_async_effect(
169182
function: _AsyncEffectFunc,
170183
dependencies: Sequence[Any] | ellipsis | None = ...,
184+
shutdown_timeout: float = 0.1,
171185
) -> None: ...
172186

173187

174188
def use_async_effect(
175189
function: _AsyncEffectFunc | None = None,
176190
dependencies: Sequence[Any] | ellipsis | None = ...,
191+
shutdown_timeout: float = 0.1,
177192
) -> Callable[[_AsyncEffectFunc], None] | None:
178-
"""See the full :ref:`Use Effect` docs for details
193+
"""
194+
A hook that manages an asynchronous side effect in a React-like component.
179195
180-
Parameters:
196+
This hook allows you to run an asynchronous function as a side effect and
197+
ensures that the effect is properly cleaned up when the component is
198+
re-rendered or unmounted.
199+
200+
Args:
181201
function:
182202
Applies the effect and can return a clean-up function
183203
dependencies:
184204
Dependencies for the effect. The effect will only trigger if the identity
185205
of any value in the given sequence changes (i.e. their :func:`id` is
186206
different). By default these are inferred based on local variables that are
187207
referenced by the given function.
208+
shutdown_timeout:
209+
The amount of time (in seconds) to wait for the effect to complete before
210+
forcing a shutdown.
188211
189212
Returns:
190213
If not function is provided, a decorator. Otherwise ``None``.
191214
"""
192215
hook = current_hook()
193216
dependencies = _try_to_infer_closure_values(function, dependencies)
194217
memoize = use_memo(dependencies=dependencies)
195-
last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None)
218+
cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None)
196219

197-
def add_effect(function: _AsyncEffectFunc) -> None:
198-
def sync_executor() -> _EffectCleanFunc | None:
199-
task = asyncio.create_task(function())
200-
201-
def clean_future() -> None:
202-
if not task.cancel():
203-
try:
204-
clean = task.result()
205-
except asyncio.CancelledError:
206-
pass
207-
else:
208-
if clean is not None:
209-
clean()
220+
def decorator(func: _AsyncEffectFunc) -> None:
221+
async def effect(stop: asyncio.Event) -> None:
222+
# Since the effect is asynchronous, we need to make sure we
223+
# always clean up the previous effect's resources
224+
run_effect_cleanup(cleanup_func)
210225

211-
return clean_future
226+
# Execute the effect in a background task
227+
task = asyncio.create_task(func())
212228

213-
async def effect(stop: asyncio.Event) -> None:
214-
if last_clean_callback.current is not None:
215-
last_clean_callback.current()
216-
last_clean_callback.current = None
217-
clean = last_clean_callback.current = sync_executor()
229+
# Wait until we get the signal to stop this effect
218230
await stop.wait()
219-
if clean is not None:
220-
clean()
231+
232+
# If renders are queued back-to-back, the effect might not have
233+
# completed. So, we give the task a small amount of time to finish.
234+
# If it manages to finish, we can obtain a clean-up function.
235+
results, _ = await asyncio.wait([task], timeout=shutdown_timeout)
236+
if results:
237+
cleanup_func.current = results.pop().result()
238+
239+
# Run the clean-up function when the effect is stopped,
240+
# if it hasn't been run already by a new effect
241+
run_effect_cleanup(cleanup_func)
242+
243+
# Cancel the task if it's still running
244+
task.cancel()
221245

222246
return memoize(lambda: hook.add_effect(effect))
223247

224-
if function is not None:
225-
add_effect(function)
248+
# Handle decorator usage
249+
if function:
250+
decorator(function)
226251
return None
227-
228-
return add_effect
252+
return decorator
229253

230254

231255
def use_debug_value(
@@ -595,3 +619,9 @@ def strictly_equal(x: Any, y: Any) -> bool:
595619

596620
# Fallback to identity check
597621
return x is y # pragma: no cover
622+
623+
624+
def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None:
625+
if cleanup_func.current:
626+
cleanup_func.current()
627+
cleanup_func.current = None

0 commit comments

Comments
 (0)