From a4feb9a8e0f13b84a50fde9a40daee925025a691 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 2 Feb 2025 22:54:28 -0800 Subject: [PATCH 1/8] Remove unneeded test deps from pyproject --- pyproject.toml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7794b65d7..3ba74163f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,15 +93,13 @@ testing = ["playwright"] [tool.hatch.envs.hatch-test] extra-dependencies = [ "pytest-sugar", - "pytest-asyncio>=0.23", - "pytest-timeout", - "coverage[toml]>=6.5", + "pytest-asyncio", "responses", "playwright", "jsonpointer", "uvicorn[standard]", "jinja2-simple-tags", - "jinja2 >=3", + "jinja2", "starlette", ] From 83cc49bad571121bdf66aaf185dc8bf5a1e15e64 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 2 Feb 2025 22:54:54 -0800 Subject: [PATCH 2/8] export use_async_effect at top level --- src/reactpy/__init__.py | 2 ++ src/reactpy/core/hooks.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index a184905a6..4ca919157 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -7,6 +7,7 @@ from reactpy.core.events import event from reactpy.core.hooks import ( create_context, + use_async_effect, use_callback, use_connection, use_context, @@ -41,6 +42,7 @@ "html_to_vdom", "logging", "types", + "use_async_effect", "use_callback", "use_connection", "use_context", diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5a7cf0460..7bdcecb66 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -30,6 +30,7 @@ __all__ = [ + "use_async_effect", "use_callback", "use_effect", "use_memo", From a6e371ac84b614619328df1cf08e0a8baf2fe1d2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 01:07:42 -0800 Subject: [PATCH 3/8] use effect refactoring --- src/reactpy/core/hooks.py | 68 ++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 7bdcecb66..dbb8122e8 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -137,25 +137,29 @@ def use_effect( 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) + unmount_func: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _SyncEffectFunc) -> None: + def decorator(func: _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() + if unmount_func.current: + unmount_func.current() + unmount_func.current = None + + # Execute the effect and store the clean-up function + unmount = unmount_func.current = func() + + # Run the clean-up function when the effect is stopped await stop.wait() - if clean is not None: - clean() + if unmount: + unmount() return memoize(lambda: hook.add_effect(effect)) - if function is not None: - add_effect(function) + # Handle decorator usage + if function: + decorator(function) return None - - return add_effect + return decorator @overload @@ -193,40 +197,44 @@ def use_async_effect( 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) + unmount_func: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _AsyncEffectFunc) -> None: + def decorator(func: _AsyncEffectFunc) -> None: def sync_executor() -> _EffectCleanFunc | None: - task = asyncio.create_task(function()) + task = asyncio.create_task(func()) - def clean_future() -> None: + def unmount_executor() -> None: if not task.cancel(): try: - clean = task.result() + unmount = task.result() except asyncio.CancelledError: pass else: - if clean is not None: - clean() + if unmount: + unmount() - return clean_future + return unmount_executor 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_executor() + if unmount_func.current: + unmount_func.current() + unmount_func.current = None + + # Execute the effect and store the clean-up function + unmount = unmount_func.current = sync_executor() + + # Run the clean-up function when the effect is stopped await stop.wait() - if clean is not None: - clean() + if unmount: + unmount() return memoize(lambda: hook.add_effect(effect)) - if function is not None: - add_effect(function) + # Handle decorator usage + if function: + decorator(function) return None - - return add_effect + return decorator def use_debug_value( From 362e80d8786c4a4c7299a9c1b22f246bab4c5fce Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 01:32:19 -0800 Subject: [PATCH 4/8] functional effect timeout parameter --- src/reactpy/core/hooks.py | 70 ++++++++++++++------------------------- 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index dbb8122e8..03f0ad697 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -137,21 +137,21 @@ def use_effect( hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - unmount_func: Ref[_EffectCleanFunc | None] = use_ref(None) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) def decorator(func: _SyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: - if unmount_func.current: - unmount_func.current() - unmount_func.current = None + if cleanup_func.current: + cleanup_func.current() + cleanup_func.current = None # Execute the effect and store the clean-up function - unmount = unmount_func.current = func() + cleanup_func.current = func() # Run the clean-up function when the effect is stopped await stop.wait() - if unmount: - unmount() + if cleanup_func.current: + cleanup_func.current() return memoize(lambda: hook.add_effect(effect)) @@ -179,54 +179,34 @@ def use_async_effect( def use_async_effect( function: _AsyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> 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) - unmount_func: Ref[_EffectCleanFunc | None] = use_ref(None) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) def decorator(func: _AsyncEffectFunc) -> None: - def sync_executor() -> _EffectCleanFunc | None: - task = asyncio.create_task(func()) - - def unmount_executor() -> None: - if not task.cancel(): - try: - unmount = task.result() - except asyncio.CancelledError: - pass - else: - if unmount: - unmount() - - return unmount_executor - async def effect(stop: asyncio.Event) -> None: - if unmount_func.current: - unmount_func.current() - unmount_func.current = None + if cleanup_func.current: + cleanup_func.current() + cleanup_func.current = None - # Execute the effect and store the clean-up function - unmount = unmount_func.current = sync_executor() + # Execute the effect in a background task + task = asyncio.create_task(func()) - # Run the clean-up function when the effect is stopped + # Wait until the effect is stopped await stop.wait() - if unmount: - unmount() + + # Try to fetch the results of the task + results, _ = await asyncio.wait([task], timeout=shutdown_timeout) + if results: + cleanup_func.current = results.pop().result() + if cleanup_func.current: + cleanup_func.current() + + # Cancel the task if it's still running + task.cancel() return memoize(lambda: hook.add_effect(effect)) From 5945c7aad94681b7b394963efb2860b1e7ac3f51 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 02:08:04 -0800 Subject: [PATCH 5/8] rewrite use_async_effect --- src/reactpy/core/hooks.py | 60 ++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 03f0ad697..7f4f31ea3 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -141,17 +141,19 @@ def use_effect( def decorator(func: _SyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: - if cleanup_func.current: - cleanup_func.current() - cleanup_func.current = None + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) # Execute the effect and store the clean-up function cleanup_func.current = func() - # Run the clean-up function when the effect is stopped + # Wait until we get the signal to stop this effect await stop.wait() - if cleanup_func.current: - cleanup_func.current() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) return memoize(lambda: hook.add_effect(effect)) @@ -181,6 +183,28 @@ def use_async_effect( dependencies: Sequence[Any] | ellipsis | None = ..., shutdown_timeout: float = 0.1, ) -> Callable[[_AsyncEffectFunc], None] | None: + """ + A hook that manages an asynchronous side effect in a React-like component. + + This hook allows you to run an asynchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. + + Args: + 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. + shutdown_timeout: + The amount of time (in seconds) to wait for the effect to complete before + forcing a shutdown. + + 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) @@ -188,22 +212,26 @@ def use_async_effect( def decorator(func: _AsyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: - if cleanup_func.current: - cleanup_func.current() - cleanup_func.current = None + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) # Execute the effect in a background task task = asyncio.create_task(func()) - # Wait until the effect is stopped + # Wait until we get the signal to stop this effect await stop.wait() - # Try to fetch the results of the task + # If renders are queued back-to-back, then this effect function might have + # not completed. So, we give the task a small amount of time to finish. + # If it manages to finish, we can obtain a clean-up function. results, _ = await asyncio.wait([task], timeout=shutdown_timeout) if results: cleanup_func.current = results.pop().result() - if cleanup_func.current: - cleanup_func.current() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) # Cancel the task if it's still running task.cancel() @@ -584,3 +612,9 @@ def strictly_equal(x: Any, y: Any) -> bool: # Fallback to identity check return x is y # pragma: no cover + + +def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None: + if cleanup_func.current: + cleanup_func.current() + cleanup_func.current = None From 6033d271c326e9bd44335096ff1bf12eacb4d87d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 02:18:14 -0800 Subject: [PATCH 6/8] docstring and changelog --- docs/source/about/changelog.rst | 1 + src/reactpy/core/hooks.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 7e4119f0b..9870c2b01 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -22,6 +22,7 @@ Unreleased - :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. +- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. **Changed** diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 7f4f31ea3..908c4a88d 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -120,7 +120,12 @@ def use_effect( function: _SyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Callable[[_SyncEffectFunc], None] | None: - """See the full :ref:`Use Effect` docs for details + """ + A hook that manages an synchronous side effect in a React-like component. + + This hook allows you to run a synchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. Parameters: function: From 13fae7208e6d40586e1b624a056be4611247e52b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 02:23:25 -0800 Subject: [PATCH 7/8] Fix overloads --- src/reactpy/core/hooks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 908c4a88d..70f72268d 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -173,6 +173,7 @@ async def effect(stop: asyncio.Event) -> None: def use_async_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> Callable[[_EffectApplyFunc], None]: ... @@ -180,6 +181,7 @@ def use_async_effect( def use_async_effect( function: _AsyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> None: ... @@ -227,8 +229,8 @@ async def effect(stop: asyncio.Event) -> None: # Wait until we get the signal to stop this effect await stop.wait() - # If renders are queued back-to-back, then this effect function might have - # not completed. So, we give the task a small amount of time to finish. + # If renders are queued back-to-back, the effect might not have + # completed. So, we give the task a small amount of time to finish. # If it manages to finish, we can obtain a clean-up function. results, _ = await asyncio.wait([task], timeout=shutdown_timeout) if results: From 737b54788d6eafac41d7b4f3b764ac6a5cc6ce96 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Feb 2025 02:45:09 -0800 Subject: [PATCH 8/8] Bump version --- src/reactpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 4ca919157..258cd5053 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -25,7 +25,7 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "2.0.0a0" +__version__ = "2.0.0a1" __all__ = [ "Layout",