Skip to content

Commit 61a7da3

Browse files
committed
initial implementation
1 parent 778057d commit 61a7da3

File tree

2 files changed

+102
-116
lines changed

2 files changed

+102
-116
lines changed

Diff for: src/py/reactpy/reactpy/core/hooks.py

+67-83
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections.abc import Awaitable, Sequence
4+
from collections.abc import Coroutine, Sequence
5+
from dataclasses import dataclass
56
from logging import getLogger
67
from types import FunctionType
78
from typing import (
89
TYPE_CHECKING,
910
Any,
1011
Callable,
1112
Generic,
12-
NewType,
1313
Protocol,
1414
TypeVar,
1515
cast,
1616
overload,
1717
)
18+
from weakref import WeakSet
1819

1920
from typing_extensions import TypeAlias
2021

@@ -96,30 +97,30 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
9697

9798
_EffectCleanFunc: TypeAlias = "Callable[[], None]"
9899
_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
99-
_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]"
100-
_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
100+
_AsyncEffectFunc: TypeAlias = "Callable[[asyncio.Event], Coroutine[None, None, None]]"
101+
_EffectFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
101102

102103

103104
@overload
104105
def use_effect(
105106
function: None = None,
106107
dependencies: Sequence[Any] | ellipsis | None = ...,
107-
) -> Callable[[_EffectApplyFunc], None]:
108+
) -> Callable[[_EffectFunc], None]:
108109
...
109110

110111

111112
@overload
112113
def use_effect(
113-
function: _EffectApplyFunc,
114+
function: _EffectFunc,
114115
dependencies: Sequence[Any] | ellipsis | None = ...,
115116
) -> None:
116117
...
117118

118119

119120
def use_effect(
120-
function: _EffectApplyFunc | None = None,
121+
function: _EffectFunc | None = None,
121122
dependencies: Sequence[Any] | ellipsis | None = ...,
122-
) -> Callable[[_EffectApplyFunc], None] | None:
123+
) -> Callable[[_EffectFunc], None] | None:
123124
"""See the full :ref:`Use Effect` docs for details
124125
125126
Parameters:
@@ -135,37 +136,25 @@ def use_effect(
135136
If not function is provided, a decorator. Otherwise ``None``.
136137
"""
137138
hook = current_hook()
138-
139139
dependencies = _try_to_infer_closure_values(function, dependencies)
140140
memoize = use_memo(dependencies=dependencies)
141-
last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None)
142-
143-
def add_effect(function: _EffectApplyFunc) -> None:
144-
if not asyncio.iscoroutinefunction(function):
145-
sync_function = cast(_SyncEffectFunc, function)
146-
else:
147-
async_function = cast(_AsyncEffectFunc, function)
148-
149-
def sync_function() -> _EffectCleanFunc | None:
150-
future = asyncio.ensure_future(async_function())
141+
effect_info: Ref[_EffectInfo | None] = use_ref(None)
151142

152-
def clean_future() -> None:
153-
if not future.cancel():
154-
clean = future.result()
155-
if clean is not None:
156-
clean()
143+
def add_effect(function: _EffectFunc) -> None:
144+
effect = _cast_async_effect(function)
157145

158-
return clean_future
146+
async def create_effect_task() -> _EffectInfo:
147+
if effect_info.current is not None:
148+
last_effect_info = effect_info.current
149+
last_effect_info.stop.set()
150+
await last_effect_info.task
159151

160-
def effect() -> None:
161-
if last_clean_callback.current is not None:
162-
last_clean_callback.current()
152+
stop = asyncio.Event()
153+
info = _EffectInfo(asyncio.create_task(effect(stop)), stop)
154+
effect_info.current = info
155+
return info
163156

164-
clean = last_clean_callback.current = sync_function()
165-
if clean is not None:
166-
hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean)
167-
168-
return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect))
157+
return memoize(lambda: hook.add_effect(create_effect_task))
169158

170159
if function is not None:
171160
add_effect(function)
@@ -174,6 +163,19 @@ def effect() -> None:
174163
return add_effect
175164

176165

166+
def _cast_async_effect(function: Callable[..., Any]) -> _AsyncEffectFunc:
167+
if asyncio.iscoroutinefunction(function):
168+
return function
169+
170+
async def wrapper(stop: asyncio.Event) -> None:
171+
cleanup = function()
172+
await stop.wait()
173+
if cleanup is not None:
174+
cleanup()
175+
176+
return wrapper
177+
178+
177179
def use_debug_value(
178180
message: Any | Callable[[], Any],
179181
dependencies: Sequence[Any] | ellipsis | None = ...,
@@ -507,19 +509,6 @@ def current_hook() -> LifeCycleHook:
507509
_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
508510

509511

510-
EffectType = NewType("EffectType", str)
511-
"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved"""
512-
513-
COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER")
514-
"""An effect that will be triggered each time a component renders"""
515-
516-
LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER")
517-
"""An effect that will be triggered each time a layout renders"""
518-
519-
COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT")
520-
"""An effect that will be triggered just before the component is unmounted"""
521-
522-
523512
class LifeCycleHook:
524513
"""Defines the life cycle of a layout component.
525514
@@ -590,7 +579,8 @@ class LifeCycleHook:
590579
"__weakref__",
591580
"_context_providers",
592581
"_current_state_index",
593-
"_event_effects",
582+
"_effect_funcs",
583+
"_effect_infos",
594584
"_is_rendering",
595585
"_rendered_atleast_once",
596586
"_schedule_render_callback",
@@ -612,11 +602,8 @@ def __init__(
612602
self._rendered_atleast_once = False
613603
self._current_state_index = 0
614604
self._state: tuple[Any, ...] = ()
615-
self._event_effects: dict[EffectType, list[Callable[[], None]]] = {
616-
COMPONENT_DID_RENDER_EFFECT: [],
617-
LAYOUT_DID_RENDER_EFFECT: [],
618-
COMPONENT_WILL_UNMOUNT_EFFECT: [],
619-
}
605+
self._effect_funcs: list[_EffectStarter] = []
606+
self._effect_infos: WeakSet[_EffectInfo] = WeakSet()
620607

621608
def schedule_render(self) -> None:
622609
if self._is_rendering:
@@ -635,9 +622,9 @@ def use_state(self, function: Callable[[], _Type]) -> _Type:
635622
self._current_state_index += 1
636623
return result
637624

638-
def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None:
625+
def add_effect(self, start_effect: _EffectStarter) -> None:
639626
"""Trigger a function on the occurrence of the given effect type"""
640-
self._event_effects[effect_type].append(function)
627+
self._effect_funcs.append(start_effect)
641628

642629
def set_context_provider(self, provider: ContextProvider[Any]) -> None:
643630
self._context_providers[provider.type] = provider
@@ -647,52 +634,40 @@ def get_context_provider(
647634
) -> ContextProvider[_Type] | None:
648635
return self._context_providers.get(context)
649636

650-
def affect_component_will_render(self, component: ComponentType) -> None:
637+
async def affect_component_will_render(self, component: ComponentType) -> None:
651638
"""The component is about to render"""
652639
self.component = component
653-
654640
self._is_rendering = True
655-
self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear()
641+
self.set_current()
656642

657-
def affect_component_did_render(self) -> None:
643+
async def affect_component_did_render(self) -> None:
658644
"""The component completed a render"""
645+
self.unset_current()
659646
del self.component
660-
661-
component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT]
662-
for effect in component_did_render_effects:
663-
try:
664-
effect()
665-
except Exception:
666-
logger.exception(f"Component post-render effect {effect} failed")
667-
component_did_render_effects.clear()
668-
669647
self._is_rendering = False
670648
self._rendered_atleast_once = True
671649
self._current_state_index = 0
672650

673-
def affect_layout_did_render(self) -> None:
651+
async def affect_layout_did_render(self) -> None:
674652
"""The layout completed a render"""
675-
layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT]
676-
for effect in layout_did_render_effects:
677-
try:
678-
effect()
679-
except Exception:
680-
logger.exception(f"Layout post-render effect {effect} failed")
681-
layout_did_render_effects.clear()
653+
for start_effect in self._effect_funcs:
654+
effect_info = await start_effect()
655+
self._effect_infos.add(effect_info)
656+
self._effect_funcs.clear()
682657

683658
if self._schedule_render_later:
684659
self._schedule_render()
685660
self._schedule_render_later = False
686661

687-
def affect_component_will_unmount(self) -> None:
662+
async def affect_component_will_unmount(self) -> None:
688663
"""The component is about to be removed from the layout"""
689-
will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT]
690-
for effect in will_unmount_effects:
691-
try:
692-
effect()
693-
except Exception:
694-
logger.exception(f"Pre-unmount effect {effect} failed")
695-
will_unmount_effects.clear()
664+
for infos in self._effect_infos:
665+
infos.stop.set()
666+
try:
667+
await asyncio.gather(*[i.task for i in self._effect_infos])
668+
except Exception:
669+
logger.exception("Error during effect cancellation")
670+
self._effect_infos.clear()
696671

697672
def set_current(self) -> None:
698673
"""Set this hook as the active hook in this thread
@@ -720,6 +695,15 @@ def _schedule_render(self) -> None:
720695
)
721696

722697

698+
_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, _EffectInfo]]"
699+
700+
701+
@dataclass(frozen=True)
702+
class _EffectInfo:
703+
task: asyncio.Task[None]
704+
stop: asyncio.Event
705+
706+
723707
def strictly_equal(x: Any, y: Any) -> bool:
724708
"""Check if two values are identical or, for a limited set or types, equal.
725709

0 commit comments

Comments
 (0)