Skip to content

Commit 5006b62

Browse files
committed
async effects get prior task and interupt event
async effects without arguments are now deprecated
1 parent 6a4c947 commit 5006b62

File tree

9 files changed

+137
-50
lines changed

9 files changed

+137
-50
lines changed

docs/source/about/changelog.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ more info, see the :ref:`Contributor Guide <Creating a Changelog Entry>`.
2323
Unreleased
2424
----------
2525

26-
No changes.
26+
2727

2828

2929
v1.0.0

docs/source/reference/_examples/simple_dashboard.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async def interval() -> None:
9797
await asyncio.sleep(rate - (time.time() - usage_time.current))
9898
usage_time.current = time.time()
9999

100-
return asyncio.ensure_future(interval())
100+
return asyncio.create_task(interval())
101101

102102

103103
reactpy.run(RandomWalk)

docs/source/reference/_examples/snake_game.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ async def interval() -> None:
135135
await asyncio.sleep(rate - (time.time() - usage_time.current))
136136
usage_time.current = time.time()
137137

138-
return asyncio.ensure_future(interval())
138+
return asyncio.create_task(interval())
139139

140140

141141
def create_grid(grid_size, block_scale):

src/reactpy/backend/flask.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ async def main() -> None:
242242
async_recv_queue.get,
243243
)
244244

245-
main_future = asyncio.ensure_future(main(), loop=loop)
245+
main_future = asyncio.create_task(main(), loop=loop)
246246

247247
dispatch_thread_info_ref.current = _DispatcherThreadInfo(
248248
dispatch_loop=loop,

src/reactpy/backend/tornado.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ async def recv() -> Any:
190190
return json.loads(await message_queue.get())
191191

192192
self._message_queue = message_queue
193-
self._dispatch_future = asyncio.ensure_future(
193+
self._dispatch_future = asyncio.create_task(
194194
serve_layout(
195195
Layout(
196196
ConnectionContext(

src/reactpy/core/hooks.py

+59-40
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

33
import asyncio
4+
from asyncio import Event, Task, create_task
5+
from inspect import iscoroutinefunction, isfunction, signature
46
from logging import getLogger
57
from types import FunctionType
68
from typing import (
79
TYPE_CHECKING,
810
Any,
9-
Awaitable,
1011
Callable,
1112
Generic,
1213
NewType,
@@ -15,14 +16,15 @@
1516
cast,
1617
overload,
1718
)
19+
from warnings import warn
1820

19-
from typing_extensions import Protocol, TypeAlias
21+
from typing_extensions import Literal, Protocol, TypeAlias, TypedDict
2022

2123
from reactpy.config import REACTPY_DEBUG_MODE
2224
from reactpy.utils import Ref
2325

2426
from ._thread_local import ThreadLocal
25-
from .types import ComponentType, Key, State, VdomDict
27+
from .types import AsyncEffect, ComponentType, Key, State, SyncEffect, VdomDict
2628

2729

2830
if not TYPE_CHECKING:
@@ -96,32 +98,26 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
9698
self.dispatch = dispatch
9799

98100

99-
_EffectCleanFunc: TypeAlias = "Callable[[], None]"
100-
_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
101-
_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]"
102-
_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
103-
104-
105101
@overload
106102
def use_effect(
107103
function: None = None,
108104
dependencies: Sequence[Any] | ellipsis | None = ...,
109-
) -> Callable[[_EffectApplyFunc], None]:
105+
) -> Callable[[SyncEffect | AsyncEffect], None]:
110106
...
111107

112108

113109
@overload
114110
def use_effect(
115-
function: _EffectApplyFunc,
111+
function: SyncEffect | AsyncEffect,
116112
dependencies: Sequence[Any] | ellipsis | None = ...,
117113
) -> None:
118114
...
119115

120116

121117
def use_effect(
122-
function: _EffectApplyFunc | None = None,
118+
function: SyncEffect | AsyncEffect | None = None,
123119
dependencies: Sequence[Any] | ellipsis | None = ...,
124-
) -> Callable[[_EffectApplyFunc], None] | None:
120+
) -> Callable[[SyncEffect | AsyncEffect], None] | None:
125121
"""See the full :ref:`Use Effect` docs for details
126122
127123
Parameters:
@@ -140,42 +136,65 @@ def use_effect(
140136

141137
dependencies = _try_to_infer_closure_values(function, dependencies)
142138
memoize = use_memo(dependencies=dependencies)
143-
last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None)
139+
state: _EffectState = _use_const(
140+
lambda: {"prior_task": None, "prior_callback": None}
141+
)
144142

145-
def add_effect(function: _EffectApplyFunc) -> None:
146-
if not asyncio.iscoroutinefunction(function):
147-
sync_function = cast(_SyncEffectFunc, function)
148-
else:
149-
async_function = cast(_AsyncEffectFunc, function)
143+
def add_effect(function: SyncEffect | AsyncEffect) -> None:
144+
memoize(lambda: _add_effect(hook, state, function))
145+
return None
150146

151-
def sync_function() -> _EffectCleanFunc | None:
152-
future = asyncio.ensure_future(async_function())
147+
if function is not None:
148+
add_effect(function)
149+
return None
150+
else:
151+
return add_effect
153152

154-
def clean_future() -> None:
155-
if not future.cancel():
156-
clean = future.result()
157-
if clean is not None:
158-
clean()
159153

160-
return clean_future
154+
def _add_effect(
155+
hook: LifeCycleHook, state: _EffectState, function: SyncEffect | AsyncEffect
156+
) -> None:
157+
sync_function: SyncEffect
158+
159+
if iscoroutinefunction(function):
160+
if not signature(function).parameters: # pragma: no cover
161+
warn(
162+
f"Async effect functions {function} should accept two arguments - the "
163+
"prior task and an interrupt event. This will be required in a future "
164+
"release.",
165+
DeprecationWarning,
166+
)
167+
original_function = function
161168

162-
def effect() -> None:
163-
if last_clean_callback.current is not None:
164-
last_clean_callback.current()
169+
def function(prior_task: Task | None, _: Event) -> None:
170+
if prior_task is not None:
171+
prior_task.cancel()
172+
return original_function()
165173

166-
clean = last_clean_callback.current = sync_function()
167-
if clean is not None:
168-
hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean)
174+
def sync_function() -> Callable[[], None]:
175+
interupt = Event()
176+
state["prior_task"] = create_task(function(state["prior_task"], interupt))
177+
return interupt.set
169178

170-
return None
179+
elif isfunction(function):
180+
sync_function = function
181+
else:
182+
raise TypeError(f"Expected a function, not {function!r}")
171183

172-
return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect))
184+
def effect() -> None:
185+
prior_callback = state["prior_callback"]
186+
if prior_callback is not None:
187+
prior_callback()
188+
next_callback = state["prior_callback"] = sync_function()
189+
if next_callback is not None:
190+
hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, next_callback)
173191

174-
if function is not None:
175-
add_effect(function)
176-
return None
177-
else:
178-
return add_effect
192+
hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)
193+
194+
195+
class _EffectState(TypedDict):
196+
prior_task: Task | None
197+
prior_callback: Callable[[], None] | None
179198

180199

181200
def use_debug_value(

src/reactpy/core/types.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import sys
45
from collections import namedtuple
56
from collections.abc import Sequence
67
from types import TracebackType
78
from typing import (
89
TYPE_CHECKING,
910
Any,
11+
Awaitable,
1012
Callable,
1113
Generic,
1214
Mapping,
@@ -233,3 +235,12 @@ class LayoutEventMessage(TypedDict):
233235
"""The ID of the event handler."""
234236
data: Sequence[Any]
235237
"""A list of event data passed to the event handler."""
238+
239+
240+
SyncEffect: TypeAlias = "Callable[[], None | Callable[[], None]]"
241+
"""A synchronous function which can be run by the :func:`use_effect` hook"""
242+
243+
AsyncEffect: TypeAlias = (
244+
"Callable[[asyncio.Task | None, asyncio.Event], Awaitable[None]]"
245+
)
246+
"""A asynchronous function which can be run by the :func:`use_effect` hook"""

tests/test_core/test_hooks.py

+61-4
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ async def test_use_async_effect():
479479
@reactpy.component
480480
def ComponentWithAsyncEffect():
481481
@reactpy.hooks.use_effect
482-
async def effect():
482+
async def effect(prior, interupt):
483483
effect_ran.set()
484484

485485
return reactpy.html.div()
@@ -498,9 +498,10 @@ async def test_use_async_effect_cleanup():
498498
@component_hook.capture
499499
def ComponentWithAsyncEffect():
500500
@reactpy.hooks.use_effect(dependencies=None) # force this to run every time
501-
async def effect():
501+
async def effect(prior, interupt):
502502
effect_ran.set()
503-
return cleanup_ran.set
503+
await interupt.wait()
504+
cleanup_ran.set()
504505

505506
return reactpy.html.div()
506507

@@ -514,7 +515,63 @@ async def effect():
514515
await asyncio.wait_for(cleanup_ran.wait(), 1)
515516

516517

517-
async def test_use_async_effect_cancel(caplog):
518+
async def test_use_async_effect_cancel():
519+
component_hook = HookCatcher()
520+
effect_ran = asyncio.Event()
521+
effect_was_cancelled = asyncio.Event()
522+
523+
event_that_never_occurs = asyncio.Event()
524+
525+
@reactpy.component
526+
@component_hook.capture
527+
def ComponentWithLongWaitingEffect():
528+
@reactpy.hooks.use_effect(dependencies=None) # force this to run every time
529+
async def effect(prior, interupt):
530+
if prior is not None:
531+
prior.cancel()
532+
effect_ran.set()
533+
try:
534+
await event_that_never_occurs.wait()
535+
except asyncio.CancelledError:
536+
effect_was_cancelled.set()
537+
raise
538+
539+
return reactpy.html.div()
540+
541+
async with reactpy.Layout(ComponentWithLongWaitingEffect()) as layout:
542+
await layout.render()
543+
544+
await effect_ran.wait()
545+
component_hook.latest.schedule_render()
546+
547+
await layout.render()
548+
549+
await asyncio.wait_for(effect_was_cancelled.wait(), 1)
550+
551+
# So I know we said the event never occurs but... to ensure the effect's future is
552+
# cancelled before the test is cleaned up we need to set the event. This is because
553+
# the cancellation doesn't propogate before the test is resolved which causes
554+
# delayed log messages that impact other tests.
555+
event_that_never_occurs.set()
556+
557+
558+
async def test_deprecated_use_async_effect_no_arguments():
559+
effect_ran = asyncio.Event()
560+
561+
@reactpy.component
562+
def ComponentWithAsyncEffect():
563+
@reactpy.hooks.use_effect
564+
async def effect():
565+
effect_ran.set()
566+
567+
return reactpy.html.div()
568+
569+
async with reactpy.Layout(ComponentWithAsyncEffect()) as layout:
570+
await layout.render()
571+
await asyncio.wait_for(effect_ran.wait(), 1)
572+
573+
574+
async def test_deprecated_use_async_effect_cancel_no_arguments():
518575
component_hook = HookCatcher()
519576
effect_ran = asyncio.Event()
520577
effect_was_cancelled = asyncio.Event()

tests/test_core/test_serve.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async def handle_event():
122122
send_queue = asyncio.Queue()
123123
recv_queue = asyncio.Queue()
124124

125-
asyncio.ensure_future(
125+
asyncio.create_task(
126126
serve_layout(
127127
reactpy.Layout(ComponentWithTwoEventHandlers()),
128128
send_queue.put,

0 commit comments

Comments
 (0)