From c5925ec2afba0522ece6f8593d4736aa526b18a8 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 26 Nov 2023 12:08:43 -0800 Subject: [PATCH] initial work on concurrent renders --- src/py/reactpy/reactpy/backend/hooks.py | 3 +- src/py/reactpy/reactpy/config.py | 8 + .../reactpy/reactpy/core/_life_cycle_hook.py | 203 ++++++++++++++ src/py/reactpy/reactpy/core/hooks.py | 265 +----------------- src/py/reactpy/reactpy/core/layout.py | 159 +++++++---- src/py/reactpy/reactpy/core/types.py | 23 ++ src/py/reactpy/reactpy/testing/common.py | 2 +- src/py/reactpy/reactpy/types.py | 2 +- src/py/reactpy/tests/conftest.py | 5 +- src/py/reactpy/tests/test_core/test_hooks.py | 17 +- 10 files changed, 373 insertions(+), 314 deletions(-) create mode 100644 src/py/reactpy/reactpy/core/_life_cycle_hook.py diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index 19ad114ed..ee4ce1b5c 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -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) diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 8371e6d08..9ed31118b 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -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.""" diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py new file mode 100644 index 000000000..cf92f2a1e --- /dev/null +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -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}" + ) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a8334458b..8cc22ba8c 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Sequence +from collections.abc import AsyncGenerator, Awaitable, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -9,7 +9,6 @@ Any, Callable, Generic, - NewType, Protocol, TypeVar, cast, @@ -19,8 +18,8 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Key, State, VdomDict +from reactpy.core._life_cycle_hook import current_hook +from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: @@ -157,15 +156,18 @@ def clean_future() -> None: return clean_future - def effect() -> None: + async def effect() -> AsyncGenerator[None, None]: if last_clean_callback.current is not None: last_clean_callback.current() clean = last_clean_callback.current = sync_function() - if clean is not None: - hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) + try: + yield + finally: + if clean is not None: + clean() - return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) + return memoize(lambda: hook.add_effect(effect)) if function is not None: add_effect(function) @@ -212,8 +214,8 @@ def context( *children: Any, value: _Type = default_value, key: Key | None = None, - ) -> ContextProvider[_Type]: - return ContextProvider( + ) -> _ContextProvider[_Type]: + return _ContextProvider( *children, value=value, key=key, @@ -225,18 +227,6 @@ def context( return context -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProvider[_Type]: - ... - - def use_context(context: Context[_Type]) -> _Type: """Get the current value for the given context type. @@ -255,10 +245,10 @@ def use_context(context: Context[_Type]) -> _Type: raise TypeError(f"{context} has no 'value' kwarg") # nocov return cast(_Type, context.__kwdefaults__["value"]) - return provider._value + return provider.value -class ContextProvider(Generic[_Type]): +class _ContextProvider(Generic[_Type]): def __init__( self, *children: Any, @@ -269,7 +259,7 @@ def __init__( self.children = children self.key = key self.type = type - self._value = value + self.value = value def render(self) -> VdomDict: current_hook().set_context_provider(self) @@ -495,231 +485,6 @@ def _try_to_infer_closure_values( return values -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_stack.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] - - -_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) - - -EffectType = NewType("EffectType", str) -"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved""" - -COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER") -"""An effect that will be triggered each time a component renders""" - -LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER") -"""An effect that will be triggered each time a layout renders""" - -COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT") -"""An effect that will be triggered just before the component is unmounted""" - - -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.hooks import ( - current_hook, - LifeCycleHook, - 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 --- - - hook.affect_component_will_render(...) - - hook.set_current() - - 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: - hook.unset_current() - - hook.affect_component_did_render() - - # This should only be called after the full set of changes associated with a - # given render have been completed. - 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", - "_event_effects", - "_is_rendering", - "_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], ContextProvider[Any]] = {} - self._schedule_render_callback = schedule_render - self._schedule_render_later = False - self._is_rendering = False - self._rendered_atleast_once = False - self._current_state_index = 0 - self._state: tuple[Any, ...] = () - self._event_effects: dict[EffectType, list[Callable[[], None]]] = { - COMPONENT_DID_RENDER_EFFECT: [], - LAYOUT_DID_RENDER_EFFECT: [], - COMPONENT_WILL_UNMOUNT_EFFECT: [], - } - - def schedule_render(self) -> None: - if self._is_rendering: - self._schedule_render_later = True - else: - self._schedule_render() - - def use_state(self, function: Callable[[], _Type]) -> _Type: - 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_type: EffectType, function: Callable[[], None]) -> None: - """Trigger a function on the occurrence of the given effect type""" - self._event_effects[effect_type].append(function) - - def set_context_provider(self, provider: ContextProvider[Any]) -> None: - self._context_providers[provider.type] = provider - - def get_context_provider( - self, context: Context[_Type] - ) -> ContextProvider[_Type] | None: - return self._context_providers.get(context) - - def affect_component_will_render(self, component: ComponentType) -> None: - """The component is about to render""" - self.component = component - - self._is_rendering = True - self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear() - - def affect_component_did_render(self) -> None: - """The component completed a render""" - del self.component - - component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT] - for effect in component_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Component post-render effect {effect} failed") - component_did_render_effects.clear() - - self._is_rendering = False - self._rendered_atleast_once = True - self._current_state_index = 0 - - def affect_layout_did_render(self) -> None: - """The layout completed a render""" - layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT] - for effect in layout_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Layout post-render effect {effect} failed") - layout_did_render_effects.clear() - - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False - - def affect_component_will_unmount(self) -> None: - """The component is about to be removed from the layout""" - will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT] - for effect in will_unmount_effects: - try: - effect() - except Exception: - logger.exception(f"Pre-unmount effect {effect} failed") - will_unmount_effects.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_stack.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_stack.get().pop() is not self: - raise RuntimeError("Hook stack is in an invalid state") # nocov - - def _schedule_render(self) -> None: - try: - self._schedule_render_callback() - except Exception: - logger.exception( - f"Failed to schedule render via {self._schedule_render_callback}" - ) - - def strictly_equal(x: Any, y: Any) -> bool: """Check if two values are identical or, for a limited set or types, equal. diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 3252ba75c..a57d7157c 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -1,10 +1,19 @@ from __future__ import annotations import abc -import asyncio +from asyncio import ( + FIRST_COMPLETED, + Event, + Queue, + Task, + create_task, + gather, + get_running_loop, + wait, +) from collections import Counter from collections.abc import Iterator -from contextlib import ExitStack +from contextlib import AsyncExitStack from logging import getLogger from typing import ( Any, @@ -18,8 +27,12 @@ from uuid import uuid4 from weakref import ref as weakref -from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE -from reactpy.core.hooks import LifeCycleHook +from reactpy.config import ( + REACTPY_CHECK_VDOM_SPEC, + REACTPY_CONCURRENT_RENDERING, + REACTPY_DEBUG_MODE, +) +from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( ComponentType, EventHandlerDict, @@ -41,6 +54,7 @@ class Layout: "root", "_event_handlers", "_rendering_queue", + "_render_tasks", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", ) @@ -58,6 +72,7 @@ def __init__(self, root: ComponentType) -> None: async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} + self._render_tasks: set[Task[LayoutUpdateMessage]] = set() self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) @@ -72,7 +87,8 @@ async def __aenter__(self) -> Layout: async def __aexit__(self, *exc: Any) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] - self._unmount_model_states([root_model_state]) + await gather(*self._render_tasks, return_exceptions=True) + await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager del self._event_handlers @@ -100,6 +116,12 @@ async def deliver(self, event: LayoutEventMessage) -> None: ) async def render(self) -> LayoutUpdateMessage: + if REACTPY_CONCURRENT_RENDERING.current: + return await self._concurrent_render() + else: # nocov + return await self._serial_render() + + async def _serial_render(self) -> LayoutUpdateMessage: # nocov """Await the next available render. This will block until a component is updated""" while True: model_state_id = await self._rendering_queue.get() @@ -111,19 +133,52 @@ async def render(self) -> LayoutUpdateMessage: f"{model_state_id!r} - component already unmounted" ) else: - update = self._create_layout_update(model_state) - if REACTPY_CHECK_VDOM_SPEC.current: - root_id = self._root_life_cycle_state_id - root_model = self._model_states_by_life_cycle_state_id[root_id] - validate_vdom_json(root_model.model.current) - return update - - def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: + return await self._create_layout_update(model_state) + + async def _concurrent_render(self) -> LayoutUpdateMessage: + """Await the next available render. This will block until a component is updated""" + while True: + render_completed = ( + create_task(wait(self._render_tasks, return_when=FIRST_COMPLETED)) + if self._render_tasks + else get_running_loop().create_future() + ) + await wait( + (create_task(self._rendering_queue.ready()), render_completed), + return_when=FIRST_COMPLETED, + ) + if render_completed.done(): + done, _ = await render_completed + update_task: Task[LayoutUpdateMessage] = done.pop() + self._render_tasks.remove(update_task) + return update_task.result() + else: + model_state_id = await self._rendering_queue.get() + try: + model_state = self._model_states_by_life_cycle_state_id[ + model_state_id + ] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{model_state_id!r} - component already unmounted" + ) + else: + self._render_tasks.add( + create_task(self._create_layout_update(model_state)) + ) + + async def _create_layout_update( + self, old_state: _ModelState + ) -> LayoutUpdateMessage: new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component - with ExitStack() as exit_stack: - self._render_component(exit_stack, old_state, new_state, component) + async with AsyncExitStack() as exit_stack: + await self._render_component(exit_stack, old_state, new_state, component) + + if REACTPY_CHECK_VDOM_SPEC.current: + validate_vdom_json(new_state.model.current) return { "type": "layout-update", @@ -131,9 +186,9 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: "model": new_state.model.current, } - def _render_component( + async def _render_component( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, component: ComponentType, @@ -143,9 +198,8 @@ def _render_component( self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state - life_cycle_hook.affect_component_will_render(component) - exit_stack.callback(life_cycle_hook.affect_layout_did_render) - life_cycle_hook.set_current() + await life_cycle_hook.affect_component_will_render(component) + exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render) try: raw_model = component.render() # wrap the model in a fragment (i.e. tagName="") to ensure components have @@ -154,7 +208,7 @@ def _render_component( wrapper_model: VdomDict = {"tagName": ""} if raw_model is not None: wrapper_model["children"] = [raw_model] - self._render_model(exit_stack, old_state, new_state, wrapper_model) + await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -166,8 +220,7 @@ def _render_component( ), } finally: - life_cycle_hook.unset_current() - life_cycle_hook.affect_component_did_render() + await life_cycle_hook.affect_component_did_render() try: parent = new_state.parent @@ -188,9 +241,9 @@ def _render_component( ], } - def _render_model( + async def _render_model( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_model: Any, @@ -205,7 +258,7 @@ def _render_model( if "importSource" in raw_model: new_state.model.current["importSource"] = raw_model["importSource"] self._render_model_attributes(old_state, new_state, raw_model) - self._render_model_children( + await self._render_model_children( exit_stack, old_state, new_state, raw_model.get("children", []) ) @@ -272,9 +325,9 @@ def _render_model_event_handlers_without_old_state( return None - def _render_model_children( + async def _render_model_children( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_children: Any, @@ -284,12 +337,12 @@ def _render_model_children( if old_state is None: if raw_children: - self._render_model_children_without_old_state( + await self._render_model_children_without_old_state( exit_stack, new_state, raw_children ) return None elif not raw_children: - self._unmount_model_states(list(old_state.children_by_key.values())) + await self._unmount_model_states(list(old_state.children_by_key.values())) return None child_type_key_tuples = list(_process_child_type_and_key(raw_children)) @@ -303,7 +356,7 @@ def _render_model_children( old_keys = set(old_state.children_by_key).difference(new_keys) if old_keys: - self._unmount_model_states( + await self._unmount_model_states( [old_state.children_by_key[key] for key in old_keys] ) @@ -319,7 +372,7 @@ def _render_model_children( key, ) elif old_child_state.is_component_state: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_child_state = _make_element_model_state( new_state, index, @@ -332,7 +385,9 @@ def _render_model_children( new_state, index, ) - self._render_model(exit_stack, old_child_state, new_child_state, child) + await self._render_model( + exit_stack, old_child_state, new_child_state, child + ) new_state.append_child(new_child_state.model.current) new_state.children_by_key[key] = new_child_state elif child_type is _COMPONENT_TYPE: @@ -349,7 +404,7 @@ def _render_model_children( elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type ): - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) old_child_state = None new_child_state = _make_component_model_state( new_state, @@ -366,18 +421,18 @@ def _render_model_children( child, self._rendering_queue.put, ) - self._render_component( + await self._render_component( exit_stack, old_child_state, new_child_state, child ) else: old_child_state = old_state.children_by_key.get(key) if old_child_state is not None: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_state.append_child(child) - def _render_model_children_without_old_state( + async def _render_model_children_without_old_state( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, new_state: _ModelState, raw_children: list[Any], ) -> None: @@ -394,18 +449,18 @@ def _render_model_children_without_old_state( for index, (child, child_type, key) in enumerate(child_type_key_tuples): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) - self._render_model(exit_stack, None, child_state, child) + await self._render_model(exit_stack, None, child_state, child) new_state.append_child(child_state.model.current) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( new_state, index, key, child, self._rendering_queue.put ) - self._render_component(exit_stack, None, child_state, child) + await self._render_component(exit_stack, None, child_state, child) else: new_state.append_child(child) - def _unmount_model_states(self, old_states: list[_ModelState]) -> None: + async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount = old_states[::-1] # unmount in reversed order of rendering while to_unmount: model_state = to_unmount.pop() @@ -416,7 +471,7 @@ def _unmount_model_states(self, old_states: list[_ModelState]) -> None: if model_state.is_component_state: life_cycle_state = model_state.life_cycle_state del self._model_states_by_life_cycle_state_id[life_cycle_state.id] - life_cycle_state.hook.affect_component_will_unmount() + await life_cycle_state.hook.affect_component_will_unmount() to_unmount.extend(model_state.children_by_key.values()) @@ -538,6 +593,7 @@ class _ModelState: __slots__ = ( "__weakref__", "_parent_ref", + "_render_semaphore", "children_by_key", "index", "key", @@ -649,24 +705,27 @@ class _LifeCycleState(NamedTuple): class _ThreadSafeQueue(Generic[_Type]): - __slots__ = "_loop", "_queue", "_pending" - def __init__(self) -> None: - self._loop = asyncio.get_running_loop() - self._queue: asyncio.Queue[_Type] = asyncio.Queue() + self._loop = get_running_loop() + self._queue: Queue[_Type] = Queue() self._pending: set[_Type] = set() + self._ready = Event() def put(self, value: _Type) -> None: if value not in self._pending: self._pending.add(value) self._loop.call_soon_threadsafe(self._queue.put_nowait, value) + self._ready.set() + + async def ready(self) -> None: + """Return when the next value is available""" + await self._ready.wait() async def get(self) -> _Type: - while True: - value = await self._queue.get() - if value in self._pending: - break + value = await self._queue.get() self._pending.remove(value) + if not self._pending: + self._ready.clear() return value diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 194706c6e..e5a81814f 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -233,3 +233,26 @@ class LayoutEventMessage(TypedDict): """The ID of the event handler.""" data: Sequence[Any] """A list of event data passed to the event handler.""" + + +class Context(Protocol[_Type]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type]: + ... + + +class ContextProviderType(ComponentType, Protocol[_Type]): + """A component which provides a context value to its children""" + + type: Context[_Type] + """The context type""" + + @property + def value(self) -> _Type: + "Current context value" diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index 6d126fd2e..c799a24ff 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -13,8 +13,8 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.hooks import LifeCycleHook, current_hook def clear_reactpy_web_modules_dir() -> None: diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index 4766fe801..1ac04395a 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -6,10 +6,10 @@ from reactpy.backend.types import BackendType, Connection, Location from reactpy.core.component import Component -from reactpy.core.hooks import Context from reactpy.core.types import ( ComponentConstructor, ComponentType, + Context, EventHandlerDict, EventHandlerFunc, EventHandlerMapping, diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index 21b23c12e..be275548b 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -8,7 +8,7 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_CONCURRENT_RENDERING, REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -27,6 +27,9 @@ def pytest_addoption(parser: Parser) -> None: ) +REACTPY_CONCURRENT_RENDERING.current = True + + @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 453d07c99..6647d9b08 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -5,12 +5,8 @@ import reactpy from reactpy import html from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.hooks import ( - COMPONENT_DID_RENDER_EFFECT, - LifeCycleHook, - current_hook, - strictly_equal, -) +from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core.hooks import strictly_equal, use_effect from reactpy.core.layout import Layout from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll from reactpy.testing.logs import assert_reactpy_did_not_log @@ -1240,12 +1236,13 @@ async def test_error_in_component_effect_cleanup_is_gracefully_handled(): @reactpy.component @component_hook.capture def ComponentWithEffect(): - hook = current_hook() + @use_effect + def effect(): + def bad_cleanup(): + raise ValueError("The error message") - def bad_effect(): - raise ValueError("The error message") + return bad_cleanup - hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) return reactpy.html.div() with assert_reactpy_did_log(