diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 4e9d753d2..7e4119f0b 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -21,6 +21,7 @@ Unreleased - :pull:`1113` - Added ``reactpy.jinja.Component`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. - :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. +- :pull:`1264` - Added ``reactpy.use_async_effect`` hook. **Changed** @@ -46,6 +47,7 @@ Unreleased - :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. - :pull:`1113` - Removed deprecated function ``module_from_template``. - :pull:`1113` - Removed support for Python 3.9. +- :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead. **Fixed** diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 66913fc84..58a3e6fff 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -49,7 +49,7 @@ def RandomWalkGraph(mu, sigma): interval = use_interval(0.5) data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): await interval last_data_point = data[-1] diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 36916410e..bb4bbb541 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -90,7 +90,7 @@ def on_direction_change(event): interval = use_interval(0.5) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): if new_game_state is not None: await asyncio.sleep(1) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index f7321ef58..5a7cf0460 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -30,12 +30,12 @@ __all__ = [ - "use_state", + "use_callback", "use_effect", + "use_memo", "use_reducer", - "use_callback", "use_ref", - "use_memo", + "use_state", ] logger = getLogger(__name__) @@ -110,15 +110,15 @@ def use_effect( @overload def use_effect( - function: _EffectApplyFunc, + function: _SyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectApplyFunc | None = None, + function: _SyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: +) -> Callable[[_SyncEffectFunc], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -134,37 +134,87 @@ def use_effect( If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) + def add_effect(function: _SyncEffectFunc) -> None: + async def effect(stop: asyncio.Event) -> None: + if last_clean_callback.current is not None: + last_clean_callback.current() + last_clean_callback.current = None + clean = last_clean_callback.current = function() + await stop.wait() + if clean is not None: + clean() + + return memoize(lambda: hook.add_effect(effect)) + + if function is not None: + add_effect(function) + return None + + return add_effect + + +@overload +def use_async_effect( + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> Callable[[_EffectApplyFunc], None]: ... - def sync_function() -> _EffectCleanFunc | None: - task = asyncio.create_task(async_function()) - def clean_future() -> None: - if not task.cancel(): - try: - clean = task.result() - except asyncio.CancelledError: - pass - else: - if clean is not None: - clean() +@overload +def use_async_effect( + function: _AsyncEffectFunc, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> None: ... + + +def use_async_effect( + function: _AsyncEffectFunc | None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> Callable[[_AsyncEffectFunc], None] | None: + """See the full :ref:`Use Effect` docs for details + + Parameters: + function: + Applies the effect and can return a clean-up function + dependencies: + Dependencies for the effect. The effect will only trigger if the identity + of any value in the given sequence changes (i.e. their :func:`id` is + different). By default these are inferred based on local variables that are + referenced by the given function. + + Returns: + If not function is provided, a decorator. Otherwise ``None``. + """ + hook = current_hook() + dependencies = _try_to_infer_closure_values(function, dependencies) + memoize = use_memo(dependencies=dependencies) + last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + + def add_effect(function: _AsyncEffectFunc) -> None: + def sync_executor() -> _EffectCleanFunc | None: + task = asyncio.create_task(function()) - return clean_future + def clean_future() -> None: + if not task.cancel(): + try: + clean = task.result() + except asyncio.CancelledError: + pass + else: + if clean is not None: + clean() + + return clean_future async def effect(stop: asyncio.Event) -> None: if last_clean_callback.current is not None: last_clean_callback.current() last_clean_callback.current = None - clean = last_clean_callback.current = sync_function() + clean = last_clean_callback.current = sync_executor() await stop.wait() if clean is not None: clean() @@ -174,8 +224,8 @@ async def effect(stop: asyncio.Event) -> None: if function is not None: add_effect(function) return None - else: - return add_effect + + return add_effect def use_debug_value( diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 30ad878bb..2bd4da81e 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -481,7 +481,7 @@ async def test_use_async_effect(): @reactpy.component def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def effect(): effect_ran.set() @@ -500,7 +500,8 @@ async def test_use_async_effect_cleanup(): @reactpy.component @component_hook.capture def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time + # force this to run every time + @reactpy.hooks.use_async_effect(dependencies=None) async def effect(): effect_ran.set() return cleanup_ran.set @@ -527,7 +528,8 @@ async def test_use_async_effect_cancel(caplog): @reactpy.component @component_hook.capture def ComponentWithLongWaitingEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time + # force this to run every time + @reactpy.hooks.use_async_effect(dependencies=None) async def effect(): effect_ran.set() try: diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 234b00e9c..8b38bc825 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -12,7 +12,7 @@ from reactpy import html from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG from reactpy.core.component import component -from reactpy.core.hooks import use_effect, use_state +from reactpy.core.hooks import use_async_effect, use_effect, use_state from reactpy.core.layout import Layout from reactpy.testing import ( HookCatcher, @@ -1016,7 +1016,7 @@ def Parent(): def Child(child_key): state, set_state = use_state(0) - @use_effect + @use_async_effect async def record_if_state_is_reset(): if state: return diff --git a/tests/test_html.py b/tests/test_html.py index fe046c49e..151857a57 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -17,7 +17,7 @@ def on_click(event): set_count(count + 1) return html.div( - html.div({"id": "mount-count", "dataValue": 0}), + html.div({"id": "mount-count", "data-value": 0}), html.script( f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), @@ -57,7 +57,7 @@ def HasScript(): return html.div() else: return html.div( - html.div({"id": "run-count", "dataValue": 0}), + html.div({"id": "run-count", "data-value": 0}), html.script( { "src": f"/reactpy/modules/{file_name_template.format(src_id=src_id)}"