Skip to content

Commit d07e25e

Browse files
authored
fix use_async_effect cleanup not executed when dependencies change (#1328)
1 parent b4c06e3 commit d07e25e

File tree

4 files changed

+68
-21
lines changed

4 files changed

+68
-21
lines changed

docs/source/about/changelog.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ Unreleased
2525
- :pull:`1308` - Event data now supports accessing properties via dot notation (ex. ``event.target.value``).
2626
- :pull:`1308` - Added ``reactpy.types.Event`` to provide type hints for the standard ``data`` function argument (for example ``def on_click(event: Event): ...``).
2727
- :pull:`1113` - Added ``asgi`` and ``jinja`` installation extras (for example ``pip install reactpy[asgi, jinja]``).
28-
- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook.
2928
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI.
3029
- :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyCsr`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
3130
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ build_client = ['hatch run "src/build_scripts/build_js_client.py" {args}']
210210
build_app = ['hatch run "src/build_scripts/build_js_app.py" {args}']
211211
publish_event_to_object = [
212212
'hatch run javascript:build_event_to_object',
213-
'cd "src/js/packages/event-to-object" && bun publish --access public',
213+
'cd "src/js/packages/event-to-object" && bunx npm publish --provenance --access public',
214214
]
215215
publish_client = [
216216
'hatch run javascript:build_client',
217-
'cd "src/js/packages/@reactpy/client" && bun publish --access public',
217+
'cd "src/js/packages/@reactpy/client" && bunx npm publish --provenance --access public',
218218
]
219219

220220
#########################

src/reactpy/core/hooks.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -182,22 +182,19 @@ async def effect(stop: asyncio.Event) -> None:
182182
def use_async_effect(
183183
function: None = None,
184184
dependencies: Sequence[Any] | ellipsis | None = ...,
185-
shutdown_timeout: float = 0.1,
186185
) -> Callable[[_EffectApplyFunc], None]: ...
187186

188187

189188
@overload
190189
def use_async_effect(
191190
function: _AsyncEffectFunc,
192191
dependencies: Sequence[Any] | ellipsis | None = ...,
193-
shutdown_timeout: float = 0.1,
194192
) -> None: ...
195193

196194

197195
def use_async_effect(
198196
function: _AsyncEffectFunc | None = None,
199197
dependencies: Sequence[Any] | ellipsis | None = ...,
200-
shutdown_timeout: float = 0.1,
201198
) -> Callable[[_AsyncEffectFunc], None] | None:
202199
"""
203200
A hook that manages an asynchronous side effect in a React-like component.
@@ -214,9 +211,6 @@ def use_async_effect(
214211
of any value in the given sequence changes (i.e. their :func:`id` is
215212
different). By default these are inferred based on local variables that are
216213
referenced by the given function.
217-
shutdown_timeout:
218-
The amount of time (in seconds) to wait for the effect to complete before
219-
forcing a shutdown.
220214
221215
Returns:
222216
If not function is provided, a decorator. Otherwise ``None``.
@@ -232,26 +226,37 @@ async def effect(stop: asyncio.Event) -> None:
232226
# always clean up the previous effect's resources
233227
run_effect_cleanup(cleanup_func)
234228

235-
# Execute the effect in a background task
229+
# Execute the effect and store the clean-up function.
230+
# We run this in a task so it can be cancelled if the stop signal
231+
# is set before the effect completes.
236232
task = asyncio.create_task(func())
237233

238-
# Wait until we get the signal to stop this effect
239-
await stop.wait()
234+
# Wait for either the effect to complete or the stop signal
235+
stop_task = asyncio.create_task(stop.wait())
236+
done, _ = await asyncio.wait(
237+
[task, stop_task],
238+
return_when=asyncio.FIRST_COMPLETED,
239+
)
240240

241-
# If renders are queued back-to-back, the effect might not have
242-
# completed. So, we give the task a small amount of time to finish.
243-
# If it manages to finish, we can obtain a clean-up function.
244-
results, _ = await asyncio.wait([task], timeout=shutdown_timeout)
245-
if results:
246-
cleanup_func.current = results.pop().result()
241+
# If the effect completed first, store the cleanup function
242+
if task in done:
243+
cleanup_func.current = task.result()
244+
# Cancel the stop task since we don't need it anymore
245+
stop_task.cancel()
246+
with contextlib.suppress(asyncio.CancelledError):
247+
await stop_task
248+
# Now wait for the stop signal to run cleanup
249+
await stop.wait()
250+
else:
251+
# Stop signal came first - cancel the effect task
252+
task.cancel()
253+
with contextlib.suppress(asyncio.CancelledError):
254+
await task
247255

248256
# Run the clean-up function when the effect is stopped,
249257
# if it hasn't been run already by a new effect
250258
run_effect_cleanup(cleanup_func)
251259

252-
# Cancel the task if it's still running
253-
task.cancel()
254-
255260
return memoize(lambda: hook.add_effect(effect))
256261

257262
# Handle decorator usage

tests/test_core/test_hooks.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,49 @@ def cleanup():
476476
assert cleanup_trigger_count.current == 1
477477

478478

479+
async def test_memoized_async_effect_cleanup_only_triggered_before_new_effect():
480+
"""Test that use_async_effect cleanup is triggered when dependencies change.
481+
482+
This is the async version of test_memoized_effect_cleanup_only_triggered_before_new_effect.
483+
Regression test for https://github.com/reactive-python/reactpy/issues/1327
484+
"""
485+
component_hook = HookCatcher()
486+
set_state_callback = reactpy.Ref(None)
487+
cleanup_trigger_count = reactpy.Ref(0)
488+
489+
first_value = 1
490+
second_value = 2
491+
492+
@reactpy.component
493+
@component_hook.capture
494+
def ComponentWithEffect():
495+
state, set_state_callback.current = reactpy.hooks.use_state(first_value)
496+
497+
@reactpy.hooks.use_async_effect(dependencies=[state])
498+
async def effect():
499+
def cleanup():
500+
cleanup_trigger_count.current += 1
501+
502+
return cleanup
503+
504+
return reactpy.html.div()
505+
506+
async with Layout(ComponentWithEffect()) as layout:
507+
await layout.render()
508+
509+
assert cleanup_trigger_count.current == 0
510+
511+
component_hook.latest.schedule_render()
512+
await layout.render()
513+
514+
assert cleanup_trigger_count.current == 0
515+
516+
set_state_callback.current(second_value)
517+
await layout.render()
518+
519+
assert cleanup_trigger_count.current == 1
520+
521+
479522
async def test_use_async_effect():
480523
effect_ran = asyncio.Event()
481524

0 commit comments

Comments
 (0)