Skip to content

Commit 53ba220

Browse files
committed
make life cycle hook private (for now)
1 parent 1d4ed4c commit 53ba220

File tree

2 files changed

+261
-244
lines changed

2 files changed

+261
-244
lines changed

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

+251
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
from collections.abc import Coroutine
6+
from dataclasses import dataclass
7+
from typing import Any, Callable, Generic, Protocol, TypeVar
8+
from weakref import WeakSet
9+
10+
from typing_extensions import TypeAlias
11+
12+
from reactpy.core._thread_local import ThreadLocal
13+
from reactpy.core.types import ComponentType, Key, VdomDict
14+
15+
T = TypeVar("T")
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
def current_hook() -> LifeCycleHook:
21+
"""Get the current :class:`LifeCycleHook`"""
22+
hook_stack = _hook_stack.get()
23+
if not hook_stack:
24+
msg = "No life cycle hook is active. Are you rendering in a layout?"
25+
raise RuntimeError(msg)
26+
return hook_stack[-1]
27+
28+
29+
_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
30+
31+
32+
class Context(Protocol[T]):
33+
"""Returns a :class:`ContextProvider` component"""
34+
35+
def __call__(
36+
self,
37+
*children: Any,
38+
value: T = ...,
39+
key: Key | None = ...,
40+
) -> ContextProvider[T]:
41+
...
42+
43+
44+
class ContextProvider(Generic[T]):
45+
def __init__(
46+
self,
47+
*children: Any,
48+
value: T,
49+
key: Key | None,
50+
type: Context[T],
51+
) -> None:
52+
self.children = children
53+
self.key = key
54+
self.type = type
55+
self._value = value
56+
57+
def render(self) -> VdomDict:
58+
current_hook().set_context_provider(self)
59+
return {"tagName": "", "children": self.children}
60+
61+
def __repr__(self) -> str:
62+
return f"{type(self).__name__}({self.type})"
63+
64+
65+
@dataclass(frozen=True)
66+
class EffectInfo:
67+
task: asyncio.Task[None]
68+
stop: asyncio.Event
69+
70+
71+
class LifeCycleHook:
72+
"""Defines the life cycle of a layout component.
73+
74+
Components can request access to their own life cycle events and state through hooks
75+
while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
76+
forward by triggering events and rendering view changes.
77+
78+
Example:
79+
80+
If removed from the complexities of a layout, a very simplified full life cycle
81+
for a single component with no child components would look a bit like this:
82+
83+
.. testcode::
84+
85+
from reactpy.core.hooks import (
86+
current_hook,
87+
LifeCycleHook,
88+
COMPONENT_DID_RENDER_EFFECT,
89+
)
90+
91+
92+
# this function will come from a layout implementation
93+
schedule_render = lambda: ...
94+
95+
# --- start life cycle ---
96+
97+
hook = LifeCycleHook(schedule_render)
98+
99+
# --- start render cycle ---
100+
101+
component = ...
102+
await hook.affect_component_will_render(component)
103+
try:
104+
# render the component
105+
...
106+
107+
# the component may access the current hook
108+
assert current_hook() is hook
109+
110+
# and save state or add effects
111+
current_hook().use_state(lambda: ...)
112+
current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...)
113+
finally:
114+
await hook.affect_component_did_render()
115+
116+
# This should only be called after the full set of changes associated with a
117+
# given render have been completed.
118+
await hook.affect_layout_did_render()
119+
120+
# Typically an event occurs and a new render is scheduled, thus beginning
121+
# the render cycle anew.
122+
hook.schedule_render()
123+
124+
125+
# --- end render cycle ---
126+
127+
hook.affect_component_will_unmount()
128+
del hook
129+
130+
# --- end render cycle ---
131+
"""
132+
133+
__slots__ = (
134+
"__weakref__",
135+
"_context_providers",
136+
"_current_state_index",
137+
"_effect_funcs",
138+
"_effect_infos",
139+
"_is_rendering",
140+
"_rendered_atleast_once",
141+
"_schedule_render_callback",
142+
"_schedule_render_later",
143+
"_state",
144+
"component",
145+
)
146+
147+
component: ComponentType
148+
149+
def __init__(
150+
self,
151+
schedule_render: Callable[[], None],
152+
) -> None:
153+
self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
154+
self._schedule_render_callback = schedule_render
155+
self._schedule_render_later = False
156+
self._is_rendering = False
157+
self._rendered_atleast_once = False
158+
self._current_state_index = 0
159+
self._state: tuple[Any, ...] = ()
160+
self._effect_funcs: list[_EffectStarter] = []
161+
self._effect_infos: WeakSet[EffectInfo] = WeakSet()
162+
163+
def schedule_render(self) -> None:
164+
if self._is_rendering:
165+
self._schedule_render_later = True
166+
else:
167+
self._schedule_render()
168+
169+
def use_state(self, function: Callable[[], T]) -> T:
170+
if not self._rendered_atleast_once:
171+
# since we're not initialized yet we're just appending state
172+
result = function()
173+
self._state += (result,)
174+
else:
175+
# once finalized we iterate over each succesively used piece of state
176+
result = self._state[self._current_state_index]
177+
self._current_state_index += 1
178+
return result
179+
180+
def add_effect(self, start_effect: _EffectStarter) -> None:
181+
"""Trigger a function on the occurrence of the given effect type"""
182+
self._effect_funcs.append(start_effect)
183+
184+
def set_context_provider(self, provider: ContextProvider[Any]) -> None:
185+
self._context_providers[provider.type] = provider
186+
187+
def get_context_provider(self, context: Context[T]) -> ContextProvider[T] | None:
188+
return self._context_providers.get(context)
189+
190+
async def affect_component_will_render(self, component: ComponentType) -> None:
191+
"""The component is about to render"""
192+
self.component = component
193+
self._is_rendering = True
194+
self.set_current()
195+
196+
async def affect_component_did_render(self) -> None:
197+
"""The component completed a render"""
198+
self.unset_current()
199+
del self.component
200+
self._is_rendering = False
201+
self._rendered_atleast_once = True
202+
self._current_state_index = 0
203+
204+
async def affect_layout_did_render(self) -> None:
205+
"""The layout completed a render"""
206+
for start_effect in self._effect_funcs:
207+
effect_info = await start_effect()
208+
self._effect_infos.add(effect_info)
209+
self._effect_funcs.clear()
210+
211+
if self._schedule_render_later:
212+
self._schedule_render()
213+
self._schedule_render_later = False
214+
215+
async def affect_component_will_unmount(self) -> None:
216+
"""The component is about to be removed from the layout"""
217+
for infos in self._effect_infos:
218+
infos.stop.set()
219+
try:
220+
await asyncio.gather(*[i.task for i in self._effect_infos])
221+
except Exception:
222+
logger.exception("Error during effect cancellation")
223+
self._effect_infos.clear()
224+
225+
def set_current(self) -> None:
226+
"""Set this hook as the active hook in this thread
227+
228+
This method is called by a layout before entering the render method
229+
of this hook's associated component.
230+
"""
231+
hook_stack = _hook_stack.get()
232+
if hook_stack:
233+
parent = hook_stack[-1]
234+
self._context_providers.update(parent._context_providers)
235+
hook_stack.append(self)
236+
237+
def unset_current(self) -> None:
238+
"""Unset this hook as the active hook in this thread"""
239+
if _hook_stack.get().pop() is not self:
240+
raise RuntimeError("Hook stack is in an invalid state") # nocov
241+
242+
def _schedule_render(self) -> None:
243+
try:
244+
self._schedule_render_callback()
245+
except Exception:
246+
logger.exception(
247+
f"Failed to schedule render via {self._schedule_render_callback}"
248+
)
249+
250+
251+
_EffectStarter: TypeAlias = "Callable[[], Coroutine[None, None, EffectInfo]]"

0 commit comments

Comments
 (0)