Skip to content

Commit 1bc558b

Browse files
committed
initial work on concurrent renders
1 parent 701e462 commit 1bc558b

File tree

10 files changed

+373
-314
lines changed

10 files changed

+373
-314
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from typing import Any
55

66
from reactpy.backend.types import Connection, Location
7-
from reactpy.core.hooks import Context, create_context, use_context
7+
from reactpy.core.hooks import create_context, use_context
8+
from reactpy.core.types import Context
89

910
# backend implementations should establish this context at the root of an app
1011
ConnectionContext: Context[Connection[Any] | None] = create_context(None)

Diff for: src/py/reactpy/reactpy/config.py

+8
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
8080
validator=float,
8181
)
8282
"""A default timeout for testing utilities in ReactPy"""
83+
84+
REACTPY_CONCURRENT_RENDERING = Option(
85+
"REACTPY_CONCURRENT_RENDERING",
86+
default=False,
87+
mutable=True,
88+
validator=boolean,
89+
)
90+
"""Whether to render components concurrently. This is currently an experimental feature."""

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

+203
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from asyncio import gather
5+
from collections.abc import AsyncGenerator
6+
from typing import Any, Callable, TypeVar
7+
8+
from anyio import Semaphore
9+
10+
from reactpy.core._thread_local import ThreadLocal
11+
from reactpy.core.types import ComponentType, Context, ContextProviderType
12+
13+
T = TypeVar("T")
14+
15+
logger = logging.getLogger(__name__)
16+
17+
_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
18+
19+
20+
def current_hook() -> LifeCycleHook:
21+
"""Get the current :class:`LifeCycleHook`"""
22+
hook_stack = _HOOK_STATE.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+
class LifeCycleHook:
30+
"""Defines the life cycle of a layout component.
31+
32+
Components can request access to their own life cycle events and state through hooks
33+
while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
34+
forward by triggering events and rendering view changes.
35+
36+
Example:
37+
38+
If removed from the complexities of a layout, a very simplified full life cycle
39+
for a single component with no child components would look a bit like this:
40+
41+
.. testcode::
42+
43+
from reactpy.core._life_cycle_hooks import LifeCycleHook
44+
from reactpy.core.hooks import current_hook, COMPONENT_DID_RENDER_EFFECT
45+
46+
# this function will come from a layout implementation
47+
schedule_render = lambda: ...
48+
49+
# --- start life cycle ---
50+
51+
hook = LifeCycleHook(schedule_render)
52+
53+
# --- start render cycle ---
54+
55+
component = ...
56+
await hook.affect_component_will_render(component)
57+
try:
58+
# render the component
59+
...
60+
61+
# the component may access the current hook
62+
assert current_hook() is hook
63+
64+
# and save state or add effects
65+
current_hook().use_state(lambda: ...)
66+
current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...)
67+
finally:
68+
await hook.affect_component_did_render()
69+
70+
# This should only be called after the full set of changes associated with a
71+
# given render have been completed.
72+
await hook.affect_layout_did_render()
73+
74+
# Typically an event occurs and a new render is scheduled, thus beginning
75+
# the render cycle anew.
76+
hook.schedule_render()
77+
78+
79+
# --- end render cycle ---
80+
81+
hook.affect_component_will_unmount()
82+
del hook
83+
84+
# --- end render cycle ---
85+
"""
86+
87+
__slots__ = (
88+
"__weakref__",
89+
"_context_providers",
90+
"_current_state_index",
91+
"_effect_generators",
92+
"_render_access",
93+
"_rendered_atleast_once",
94+
"_schedule_render_callback",
95+
"_schedule_render_later",
96+
"_state",
97+
"component",
98+
)
99+
100+
component: ComponentType
101+
102+
def __init__(
103+
self,
104+
schedule_render: Callable[[], None],
105+
) -> None:
106+
self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
107+
self._schedule_render_callback = schedule_render
108+
self._schedule_render_later = False
109+
self._rendered_atleast_once = False
110+
self._current_state_index = 0
111+
self._state: tuple[Any, ...] = ()
112+
self._effect_generators: list[AsyncGenerator[None, None]] = []
113+
self._render_access = Semaphore(1) # ensure only one render at a time
114+
115+
def schedule_render(self) -> None:
116+
if self._is_rendering():
117+
self._schedule_render_later = True
118+
else:
119+
self._schedule_render()
120+
121+
def use_state(self, function: Callable[[], T]) -> T:
122+
if not self._rendered_atleast_once:
123+
# since we're not initialized yet we're just appending state
124+
result = function()
125+
self._state += (result,)
126+
else:
127+
# once finalized we iterate over each succesively used piece of state
128+
result = self._state[self._current_state_index]
129+
self._current_state_index += 1
130+
return result
131+
132+
def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None:
133+
"""Add an effect to this hook"""
134+
self._effect_generators.append(effect_func())
135+
136+
def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
137+
self._context_providers[provider.type] = provider
138+
139+
def get_context_provider(
140+
self, context: Context[T]
141+
) -> ContextProviderType[T] | None:
142+
return self._context_providers.get(context)
143+
144+
async def affect_component_will_render(self, component: ComponentType) -> None:
145+
"""The component is about to render"""
146+
await self._render_access.acquire()
147+
self.component = component
148+
self.set_current()
149+
150+
async def affect_component_did_render(self) -> None:
151+
"""The component completed a render"""
152+
self.unset_current()
153+
del self.component
154+
self._rendered_atleast_once = True
155+
self._current_state_index = 0
156+
self._render_access.release()
157+
158+
async def affect_layout_did_render(self) -> None:
159+
"""The layout completed a render"""
160+
try:
161+
await gather(*[g.asend(None) for g in self._effect_generators])
162+
except Exception:
163+
logger.exception("Error during effect execution")
164+
if self._schedule_render_later:
165+
self._schedule_render()
166+
self._schedule_render_later = False
167+
168+
async def affect_component_will_unmount(self) -> None:
169+
"""The component is about to be removed from the layout"""
170+
try:
171+
await gather(*[g.aclose() for g in self._effect_generators])
172+
except Exception:
173+
logger.exception("Error during effect cancellation")
174+
finally:
175+
self._effect_generators.clear()
176+
177+
def set_current(self) -> None:
178+
"""Set this hook as the active hook in this thread
179+
180+
This method is called by a layout before entering the render method
181+
of this hook's associated component.
182+
"""
183+
hook_stack = _HOOK_STATE.get()
184+
if hook_stack:
185+
parent = hook_stack[-1]
186+
self._context_providers.update(parent._context_providers)
187+
hook_stack.append(self)
188+
189+
def unset_current(self) -> None:
190+
"""Unset this hook as the active hook in this thread"""
191+
if _HOOK_STATE.get().pop() is not self:
192+
raise RuntimeError("Hook stack is in an invalid state") # nocov
193+
194+
def _is_rendering(self) -> bool:
195+
return self._render_access.value != 0
196+
197+
def _schedule_render(self) -> None:
198+
try:
199+
self._schedule_render_callback()
200+
except Exception:
201+
logger.exception(
202+
f"Failed to schedule render via {self._schedule_render_callback}"
203+
)

0 commit comments

Comments
 (0)