Skip to content

Commit

Permalink
initial work on concurrent renders
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed Nov 26, 2023
1 parent 3faa10f commit c5925ec
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 314 deletions.
3 changes: 2 additions & 1 deletion src/py/reactpy/reactpy/backend/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import Any

from reactpy.backend.types import Connection, Location
from reactpy.core.hooks import Context, create_context, use_context
from reactpy.core.hooks import create_context, use_context
from reactpy.core.types import Context

# backend implementations should establish this context at the root of an app
ConnectionContext: Context[Connection[Any] | None] = create_context(None)
Expand Down
8 changes: 8 additions & 0 deletions src/py/reactpy/reactpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
validator=float,
)
"""A default timeout for testing utilities in ReactPy"""

REACTPY_CONCURRENT_RENDERING = Option(
"REACTPY_CONCURRENT_RENDERING",
default=False,
mutable=True,
validator=boolean,
)
"""Whether to render components concurrently. This is currently an experimental feature."""
203 changes: 203 additions & 0 deletions src/py/reactpy/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
from __future__ import annotations

import logging
from asyncio import gather
from collections.abc import AsyncGenerator
from typing import Any, Callable, TypeVar

from anyio import Semaphore

from reactpy.core._thread_local import ThreadLocal
from reactpy.core.types import ComponentType, Context, ContextProviderType

T = TypeVar("T")

logger = logging.getLogger(__name__)

_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)


def current_hook() -> LifeCycleHook:
"""Get the current :class:`LifeCycleHook`"""
hook_stack = _HOOK_STATE.get()
if not hook_stack:
msg = "No life cycle hook is active. Are you rendering in a layout?"
raise RuntimeError(msg)
return hook_stack[-1]


class LifeCycleHook:
"""Defines the life cycle of a layout component.
Components can request access to their own life cycle events and state through hooks
while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
forward by triggering events and rendering view changes.
Example:
If removed from the complexities of a layout, a very simplified full life cycle
for a single component with no child components would look a bit like this:
.. testcode::
from reactpy.core._life_cycle_hooks import LifeCycleHook
from reactpy.core.hooks import current_hook, COMPONENT_DID_RENDER_EFFECT
# this function will come from a layout implementation
schedule_render = lambda: ...
# --- start life cycle ---
hook = LifeCycleHook(schedule_render)
# --- start render cycle ---
component = ...
await hook.affect_component_will_render(component)
try:
# render the component
...
# the component may access the current hook
assert current_hook() is hook
# and save state or add effects
current_hook().use_state(lambda: ...)
current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...)
finally:
await hook.affect_component_did_render()
# This should only be called after the full set of changes associated with a
# given render have been completed.
await hook.affect_layout_did_render()
# Typically an event occurs and a new render is scheduled, thus beginning
# the render cycle anew.
hook.schedule_render()
# --- end render cycle ---
hook.affect_component_will_unmount()
del hook
# --- end render cycle ---
"""

__slots__ = (
"__weakref__",
"_context_providers",
"_current_state_index",
"_effect_generators",
"_render_access",
"_rendered_atleast_once",
"_schedule_render_callback",
"_schedule_render_later",
"_state",
"component",
)

component: ComponentType

def __init__(
self,
schedule_render: Callable[[], None],
) -> None:
self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
self._schedule_render_callback = schedule_render
self._schedule_render_later = False
self._rendered_atleast_once = False
self._current_state_index = 0
self._state: tuple[Any, ...] = ()
self._effect_generators: list[AsyncGenerator[None, None]] = []
self._render_access = Semaphore(1) # ensure only one render at a time

def schedule_render(self) -> None:
if self._is_rendering():
self._schedule_render_later = True
else:
self._schedule_render()

def use_state(self, function: Callable[[], T]) -> T:
if not self._rendered_atleast_once:
# since we're not initialized yet we're just appending state
result = function()
self._state += (result,)
else:
# once finalized we iterate over each succesively used piece of state
result = self._state[self._current_state_index]
self._current_state_index += 1
return result

def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None:
"""Add an effect to this hook"""
self._effect_generators.append(effect_func())

def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
self._context_providers[provider.type] = provider

def get_context_provider(
self, context: Context[T]
) -> ContextProviderType[T] | None:
return self._context_providers.get(context)

async def affect_component_will_render(self, component: ComponentType) -> None:
"""The component is about to render"""
await self._render_access.acquire()
self.component = component
self.set_current()

async def affect_component_did_render(self) -> None:
"""The component completed a render"""
self.unset_current()
del self.component
self._rendered_atleast_once = True
self._current_state_index = 0
self._render_access.release()

async def affect_layout_did_render(self) -> None:
"""The layout completed a render"""
try:
await gather(*[g.asend(None) for g in self._effect_generators])
except Exception:
logger.exception("Error during effect execution")
if self._schedule_render_later:
self._schedule_render()
self._schedule_render_later = False

async def affect_component_will_unmount(self) -> None:
"""The component is about to be removed from the layout"""
try:
await gather(*[g.aclose() for g in self._effect_generators])
except Exception:
logger.exception("Error during effect cancellation")
finally:
self._effect_generators.clear()

def set_current(self) -> None:
"""Set this hook as the active hook in this thread
This method is called by a layout before entering the render method
of this hook's associated component.
"""
hook_stack = _HOOK_STATE.get()
if hook_stack:
parent = hook_stack[-1]
self._context_providers.update(parent._context_providers)
hook_stack.append(self)

def unset_current(self) -> None:
"""Unset this hook as the active hook in this thread"""
if _HOOK_STATE.get().pop() is not self:
raise RuntimeError("Hook stack is in an invalid state") # nocov

def _is_rendering(self) -> bool:
return self._render_access.value != 0

def _schedule_render(self) -> None:
try:
self._schedule_render_callback()
except Exception:
logger.exception(
f"Failed to schedule render via {self._schedule_render_callback}"
)
Loading

0 comments on commit c5925ec

Please sign in to comment.