Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create separate use_async_effect hook #1264

Merged
merged 2 commits into from
Feb 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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**

Expand Down
2 changes: 1 addition & 1 deletion docs/source/reference/_examples/simple_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/source/reference/_examples/snake_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
104 changes: 77 additions & 27 deletions src/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions tests/test_core/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_core/test_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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});'
),
Expand Down Expand Up @@ -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)}"
Expand Down