From 1bc558b03e9b0816c71f347dd276988aedb1d306 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 26 Nov 2023 12:08:43 -0800 Subject: [PATCH 01/22] 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( From dd37697eedd5107b7212640ad30f390f1dd05b7c Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 15:44:24 -0800 Subject: [PATCH 02/22] concurrent renders --- .../reactpy/reactpy/core/_life_cycle_hook.py | 25 +++++++++------- src/py/reactpy/reactpy/core/hooks.py | 16 +++++++--- src/py/reactpy/tests/test_client.py | 22 +++++++------- src/py/reactpy/tests/test_core/test_hooks.py | 20 ++++++------- src/py/reactpy/tests/test_core/test_layout.py | 4 +-- src/py/reactpy/tests/test_core/test_serve.py | 30 ++++++++++++------- src/py/reactpy/tests/tooling/aio.py | 14 +++++++++ 7 files changed, 84 insertions(+), 47 deletions(-) create mode 100644 src/py/reactpy/tests/tooling/aio.py diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index cf92f2a1e..81262c599 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -88,9 +88,10 @@ class LifeCycleHook: "__weakref__", "_context_providers", "_current_state_index", - "_effect_generators", + "_pending_effects", "_render_access", "_rendered_atleast_once", + "_running_effects", "_schedule_render_callback", "_schedule_render_later", "_state", @@ -109,7 +110,8 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () - self._effect_generators: list[AsyncGenerator[None, None]] = [] + self._pending_effects: list[AsyncGenerator[None, None]] = [] + self._running_effects: list[AsyncGenerator[None, None]] = [] self._render_access = Semaphore(1) # ensure only one render at a time def schedule_render(self) -> None: @@ -131,7 +133,7 @@ def use_state(self, function: Callable[[], T]) -> T: def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None: """Add an effect to this hook""" - self._effect_generators.append(effect_func()) + self._pending_effects.append(effect_func()) def set_context_provider(self, provider: ContextProviderType[Any]) -> None: self._context_providers[provider.type] = provider @@ -150,7 +152,6 @@ async def affect_component_will_render(self, component: ComponentType) -> None: 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() @@ -158,21 +159,25 @@ async def affect_component_did_render(self) -> None: 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]) + await gather(*[g.asend(None) for g in self._pending_effects]) + self._running_effects.extend(self._pending_effects) except Exception: - logger.exception("Error during effect execution") + logger.exception("Error during effect startup") + finally: + self._pending_effects.clear() if self._schedule_render_later: self._schedule_render() self._schedule_render_later = False + del self.component 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]) + await gather(*[g.aclose() for g in self._running_effects]) except Exception: - logger.exception("Error during effect cancellation") + logger.exception("Error during effect cleanup") finally: - self._effect_generators.clear() + self._running_effects.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread @@ -192,7 +197,7 @@ def unset_current(self) -> None: raise RuntimeError("Hook stack is in an invalid state") # nocov def _is_rendering(self) -> bool: - return self._render_access.value != 0 + return self._render_access.value == 0 def _schedule_render(self) -> None: try: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 8cc22ba8c..8d9d89629 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -160,12 +160,20 @@ 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() + cleaned = False + clean = sync_function() + + def callback() -> None: + nonlocal cleaned + if clean and not cleaned: + cleaned = True + clean() + + last_clean_callback.current = callback try: yield finally: - if clean is not None: - clean() + callback() return memoize(lambda: hook.add_effect(effect)) @@ -266,7 +274,7 @@ def render(self) -> VdomDict: return {"tagName": "", "children": self.children} def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" + return f"ContextProvider({self.type})" _ActionType = TypeVar("_ActionType") diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py index 3c7250e48..a9ff10a89 100644 --- a/src/py/reactpy/tests/test_client.py +++ b/src/py/reactpy/tests/test_client.py @@ -30,6 +30,11 @@ def SomeComponent(): ), ) + async def get_count(): + # need to refetch element because may unmount on reconnect + count = await page.wait_for_selector("#count") + return await count.get_attribute("data-count") + async with AsyncExitStack() as exit_stack: server = await exit_stack.enter_async_context(BackendFixture(port=port)) display = await exit_stack.enter_async_context( @@ -38,11 +43,10 @@ def SomeComponent(): await display.show(SomeComponent) - count = await page.wait_for_selector("#count") incr = await page.wait_for_selector("#incr") for i in range(3): - assert (await count.get_attribute("data-count")) == str(i) + await poll(get_count).until_equals(str(i)) await incr.click() # the server is disconnected but the last view state is still shown @@ -57,13 +61,7 @@ def SomeComponent(): # use mount instead of show to avoid a page refresh display.backend.mount(SomeComponent) - async def get_count(): - # need to refetch element because may unmount on reconnect - count = await page.wait_for_selector("#count") - return await count.get_attribute("data-count") - for i in range(3): - # it may take a moment for the websocket to reconnect so need to poll await poll(get_count).until_equals(str(i)) # need to refetch element because may unmount on reconnect @@ -98,11 +96,15 @@ def ButtonWithChangingColor(): button = await display.page.wait_for_selector("#my-button") - assert (await _get_style(button))["background-color"] == "red" + await poll(_get_style, button).until( + lambda style: style["background-color"] == "red" + ) for color in ["blue", "red"] * 2: await button.click() - assert (await _get_style(button))["background-color"] == color + await poll(_get_style, button).until( + lambda style, c=color: style["background-color"] == c + ) async def _get_style(element): diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 6647d9b08..b91508549 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -274,18 +274,18 @@ def double_set_state(event): first = await display.page.wait_for_selector("#first") second = await display.page.wait_for_selector("#second") - assert (await first.get_attribute("data-value")) == "0" - assert (await second.get_attribute("data-value")) == "0" + await poll(first.get_attribute, "data-value").until_equals("0") + await poll(second.get_attribute, "data-value").until_equals("0") await button.click() - assert (await first.get_attribute("data-value")) == "1" - assert (await second.get_attribute("data-value")) == "1" + await poll(first.get_attribute, "data-value").until_equals("1") + await poll(second.get_attribute, "data-value").until_equals("1") await button.click() - assert (await first.get_attribute("data-value")) == "2" - assert (await second.get_attribute("data-value")) == "2" + await poll(first.get_attribute, "data-value").until_equals("2") + await poll(second.get_attribute, "data-value").until_equals("2") async def test_use_effect_callback_occurs_after_full_render_is_complete(): @@ -558,7 +558,7 @@ def bad_effect(): return reactpy.html.div() - with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"): + with assert_reactpy_did_log(match_message=r"Error during effect startup"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -584,7 +584,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Pre-unmount effect .*? failed", + match_message=r"Error during effect cleanup", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -1003,7 +1003,7 @@ def bad_effect(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"post-render effect .*? failed", + match_message=r"Error during effect startup", error_type=ValueError, match_error="The error message", ): @@ -1246,7 +1246,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message="Component post-render effect .*? failed", + match_message="Error during effect cleanup", error_type=ValueError, match_error="The error message", ): diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 215e89137..d1140543d 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -164,7 +164,7 @@ def make_child_model(state): async def test_layout_render_error_has_partial_update_with_error_message(): @reactpy.component def Main(): - return reactpy.html.div([OkChild(), BadChild(), OkChild()]) + return reactpy.html.div(OkChild(), BadChild(), OkChild()) @reactpy.component def OkChild(): @@ -622,7 +622,7 @@ async def test_hooks_for_keyed_components_get_garbage_collected(): def Outer(): items, set_items = reactpy.hooks.use_state([1, 2, 3]) pop_item.current = lambda: set_items(items[:-1]) - return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items) + return reactpy.html.div([Inner(key=k, finalizer_id=k) for k in items]) @reactpy.component def Inner(finalizer_id): diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 64be0ec8b..9b22ee866 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -5,10 +5,12 @@ from jsonpointer import set_pointer import reactpy +from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import LayoutUpdateMessage from reactpy.testing import StaticEventHandler +from tests.tooling.aio import Event from tests.tooling.common import event_message EVENT_NAME = "on_event" @@ -96,9 +98,10 @@ async def test_dispatch(): async def test_dispatcher_handles_more_than_one_event_at_a_time(): - block_and_never_set = asyncio.Event() - will_block = asyncio.Event() - second_event_did_execute = asyncio.Event() + did_render = Event() + block_and_never_set = Event() + will_block = Event() + second_event_did_execute = Event() blocked_handler = StaticEventHandler() non_blocked_handler = StaticEventHandler() @@ -114,6 +117,10 @@ async def block_forever(): async def handle_event(): second_event_did_execute.set() + @use_effect + def set_did_render(): + did_render.set() + return reactpy.html.div( reactpy.html.button({"on_click": block_forever}), reactpy.html.button({"on_click": handle_event}), @@ -129,11 +136,12 @@ async def handle_event(): recv_queue.get, ) ) - - await recv_queue.put(event_message(blocked_handler.target)) - await will_block.wait() - - await recv_queue.put(event_message(non_blocked_handler.target)) - await second_event_did_execute.wait() - - task.cancel() + try: + await did_render.wait() + await recv_queue.put(event_message(blocked_handler.target)) + await will_block.wait() + + await recv_queue.put(event_message(non_blocked_handler.target)) + await second_event_did_execute.wait() + finally: + task.cancel() diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py new file mode 100644 index 000000000..eb3d762bf --- /dev/null +++ b/src/py/reactpy/tests/tooling/aio.py @@ -0,0 +1,14 @@ +from asyncio import Event as _Event +from asyncio import wait_for + +from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT + + +class Event(_Event): + """An event with a ``wait_for`` method.""" + + async def wait(self, timeout: float | None = None): + return await wait_for( + super().wait(), + timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current, + ) From 41c2431a187272fe044e31c42a7b4f1d54862aad Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 16:29:27 -0800 Subject: [PATCH 03/22] limit to 3.11 --- .github/workflows/.hatch-run.yml | 108 ++++++++++++++-------------- src/py/reactpy/tests/tooling/aio.py | 2 + 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index b312869e4..eb37fc6ab 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -1,59 +1,59 @@ name: hatch-run on: - workflow_call: - inputs: - job-name: - required: true - type: string - hatch-run: - required: true - type: string - runs-on-array: - required: false - type: string - default: '["ubuntu-latest"]' - python-version-array: - required: false - type: string - default: '["3.x"]' - node-registry-url: - required: false - type: string - default: "" - secrets: - node-auth-token: - required: false - pypi-username: - required: false - pypi-password: - required: false + workflow_call: + inputs: + job-name: + required: true + type: string + hatch-run: + required: true + type: string + runs-on-array: + required: false + type: string + default: '["ubuntu-latest"]' + python-version-array: + required: false + type: string + default: '["3.11"]' + node-registry-url: + required: false + type: string + default: "" + secrets: + node-auth-token: + required: false + pypi-username: + required: false + pypi-password: + required: false jobs: - hatch: - name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} - strategy: - matrix: - python-version: ${{ fromJson(inputs.python-version-array) }} - runs-on: ${{ fromJson(inputs.runs-on-array) }} - runs-on: ${{ matrix.runs-on }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "14.x" - registry-url: ${{ inputs.node-registry-url }} - - name: Pin NPM Version - run: npm install -g npm@8.19.3 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install hatch poetry - - name: Run Scripts - env: - NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} - PYPI_USERNAME: ${{ secrets.pypi-username }} - PYPI_PASSWORD: ${{ secrets.pypi-password }} - run: hatch run ${{ inputs.hatch-run }} + hatch: + name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-version-array) }} + runs-on: ${{ fromJson(inputs.runs-on-array) }} + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: "14.x" + registry-url: ${{ inputs.node-registry-url }} + - name: Pin NPM Version + run: npm install -g npm@8.19.3 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install hatch poetry + - name: Run Scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} + PYPI_USERNAME: ${{ secrets.pypi-username }} + PYPI_PASSWORD: ${{ secrets.pypi-password }} + run: hatch run ${{ inputs.hatch-run }} diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py index eb3d762bf..b0f719400 100644 --- a/src/py/reactpy/tests/tooling/aio.py +++ b/src/py/reactpy/tests/tooling/aio.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from asyncio import Event as _Event from asyncio import wait_for From f681e1ba519cdb89c81e91c3bc7cb345861a6349 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 16:33:24 -0800 Subject: [PATCH 04/22] fix docs --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 81262c599..5f7716b1d 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -40,7 +40,7 @@ class LifeCycleHook: .. testcode:: - from reactpy.core._life_cycle_hooks import LifeCycleHook + from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.hooks import current_hook, COMPONENT_DID_RENDER_EFFECT # this function will come from a layout implementation From 387dc0588f1df672d2b6e5804ecb6c85007a10bc Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 16:38:44 -0800 Subject: [PATCH 05/22] update changelog --- docs/source/about/changelog.rst | 7 +++++++ src/py/reactpy/reactpy/config.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 4 ++-- src/py/reactpy/tests/conftest.py | 7 +++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 32a3df2dc..b60ea8c5f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -28,6 +28,13 @@ Unreleased - :pull:`1118` - `module_from_template` is broken with a recent release of `requests` - :pull:`1131` - `module_from_template` did not work when using Flask backend +**Added** + +- :pull:`1165` - Concurrent renders - enable this experimental feature by setting + `REACTPY_FEATURE_CONCURRENT_RENDERING=true`. This should improve the overall + responsiveness of your app, particularly when handling larger renders that + would otherwise block faster renders from being processed. + v1.0.2 ------ diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 9ed31118b..698bf4d9e 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -81,7 +81,7 @@ def boolean(value: str | bool | int) -> bool: ) """A default timeout for testing utilities in ReactPy""" -REACTPY_CONCURRENT_RENDERING = Option( +REACTPY_FEATURE_CONCURRENT_RENDERING = Option( "REACTPY_CONCURRENT_RENDERING", default=False, mutable=True, diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index a57d7157c..0198c63f7 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -29,8 +29,8 @@ from reactpy.config import ( REACTPY_CHECK_VDOM_SPEC, - REACTPY_CONCURRENT_RENDERING, REACTPY_DEBUG_MODE, + REACTPY_FEATURE_CONCURRENT_RENDERING, ) from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( @@ -116,7 +116,7 @@ async def deliver(self, event: LayoutEventMessage) -> None: ) async def render(self) -> LayoutUpdateMessage: - if REACTPY_CONCURRENT_RENDERING.current: + if REACTPY_FEATURE_CONCURRENT_RENDERING.current: return await self._concurrent_render() else: # nocov return await self._serial_render() diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index be275548b..d76f94f79 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -8,7 +8,10 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from reactpy.config import REACTPY_CONCURRENT_RENDERING, REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import ( + REACTPY_FEATURE_CONCURRENT_RENDERING, + REACTPY_TESTING_DEFAULT_TIMEOUT, +) from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -27,7 +30,7 @@ def pytest_addoption(parser: Parser) -> None: ) -REACTPY_CONCURRENT_RENDERING.current = True +REACTPY_FEATURE_CONCURRENT_RENDERING.current = True @pytest.fixture From a4fc2f513d25969ffd56eddc1f9d885e32267c89 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 17:31:35 -0800 Subject: [PATCH 06/22] simpler add_effect interface --- .../reactpy/reactpy/core/_life_cycle_hook.py | 44 +++++++++++-------- src/py/reactpy/reactpy/core/hooks.py | 36 +++++++-------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 5f7716b1d..8c716dd36 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -2,7 +2,7 @@ import logging from asyncio import gather -from collections.abc import AsyncGenerator +from collections.abc import Awaitable from typing import Any, Callable, TypeVar from anyio import Semaphore @@ -41,7 +41,7 @@ class LifeCycleHook: .. testcode:: from reactpy.core._life_cycle_hook import LifeCycleHook - from reactpy.core.hooks import current_hook, COMPONENT_DID_RENDER_EFFECT + from reactpy.core.hooks import current_hook # this function will come from a layout implementation schedule_render = lambda: ... @@ -63,7 +63,11 @@ class LifeCycleHook: # and save state or add effects current_hook().use_state(lambda: ...) - current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) + + async def effect(): + yield + + current_hook().add_effect(effect) finally: await hook.affect_component_did_render() @@ -88,10 +92,10 @@ class LifeCycleHook: "__weakref__", "_context_providers", "_current_state_index", - "_pending_effects", + "_effect_cleanups", + "_effect_startups", "_render_access", "_rendered_atleast_once", - "_running_effects", "_schedule_render_callback", "_schedule_render_later", "_state", @@ -110,8 +114,8 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () - self._pending_effects: list[AsyncGenerator[None, None]] = [] - self._running_effects: list[AsyncGenerator[None, None]] = [] + self._effect_startups: list[Callable[[], Awaitable[None]]] = [] + self._effect_cleanups: list[Callable[[], Awaitable[None]]] = [] self._render_access = Semaphore(1) # ensure only one render at a time def schedule_render(self) -> None: @@ -131,9 +135,14 @@ def use_state(self, function: Callable[[], T]) -> T: self._current_state_index += 1 return result - def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None: + def add_effect( + self, + start_effect: Callable[[], Awaitable[None]], + clean_effect: Callable[[], Awaitable[None]], + ) -> None: """Add an effect to this hook""" - self._pending_effects.append(effect_func()) + self._effect_startups.append(start_effect) + self._effect_cleanups.append(clean_effect) def set_context_provider(self, provider: ContextProviderType[Any]) -> None: self._context_providers[provider.type] = provider @@ -155,29 +164,28 @@ async def affect_component_did_render(self) -> None: self._rendered_atleast_once = True self._current_state_index = 0 self._render_access.release() + del self.component async def affect_layout_did_render(self) -> None: """The layout completed a render""" try: - await gather(*[g.asend(None) for g in self._pending_effects]) - self._running_effects.extend(self._pending_effects) + await gather(*[start() for start in self._effect_startups]) except Exception: logger.exception("Error during effect startup") finally: - self._pending_effects.clear() - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False - del self.component + self._effect_startups.clear() + 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._running_effects]) + await gather(*[clean() for clean in self._effect_cleanups]) except Exception: logger.exception("Error during effect cleanup") finally: - self._running_effects.clear() + self._effect_cleanups.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 8d9d89629..eac5de817 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 AsyncGenerator, Awaitable, Sequence +from collections.abc import Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -95,7 +95,9 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: _EffectCleanFunc: TypeAlias = "Callable[[], None]" _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" +_AsyncEffectFunc: TypeAlias = ( + "Callable[[], Coroutine[None, None, _EffectCleanFunc | None]]" +) _EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @@ -146,36 +148,28 @@ def add_effect(function: _EffectApplyFunc) -> None: async_function = cast(_AsyncEffectFunc, function) def sync_function() -> _EffectCleanFunc | None: - future = asyncio.ensure_future(async_function()) + task = asyncio.create_task(async_function()) def clean_future() -> None: - if not future.cancel(): - clean = future.result() + if not task.cancel(): + clean = task.result() if clean is not None: clean() return clean_future - async def effect() -> AsyncGenerator[None, None]: + async def start_effect() -> None: if last_clean_callback.current is not None: last_clean_callback.current() + last_clean_callback.current = None + last_clean_callback.current = sync_function() - cleaned = False - clean = sync_function() - - def callback() -> None: - nonlocal cleaned - if clean and not cleaned: - cleaned = True - clean() - - last_clean_callback.current = callback - try: - yield - finally: - callback() + async def clean_effect() -> None: + if last_clean_callback.current is not None: + last_clean_callback.current() + last_clean_callback.current = None - return memoize(lambda: hook.add_effect(effect)) + return memoize(lambda: hook.add_effect(start_effect, clean_effect)) if function is not None: add_effect(function) From b9595ffb4194e10efce457c9d34981b0ee5b71ef Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 17:33:45 -0800 Subject: [PATCH 07/22] improve docstring --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 8c716dd36..59f38e15b 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -64,10 +64,13 @@ class LifeCycleHook: # and save state or add effects current_hook().use_state(lambda: ...) - async def effect(): - yield + async def start_effect(): + ... - current_hook().add_effect(effect) + async def stop_effect(): + ... + + current_hook().add_effect(start_effect, stop_effect) finally: await hook.affect_component_did_render() @@ -140,7 +143,12 @@ def add_effect( start_effect: Callable[[], Awaitable[None]], clean_effect: Callable[[], Awaitable[None]], ) -> None: - """Add an effect to this hook""" + """Add an effect to this hook + + Effects are started when the component is done renderig and cleaned up when the + component is removed from the layout. Any other actions (e.g. re-running the + effect if a dependency changes) are the responsibility of the effect itself. + """ self._effect_startups.append(start_effect) self._effect_cleanups.append(clean_effect) From 24575fca3e41728efa6406aad8419c4e53cb6541 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 17:37:51 -0800 Subject: [PATCH 08/22] better changelog description --- docs/source/about/changelog.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index b60ea8c5f..8a1afd544 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -30,11 +30,11 @@ Unreleased **Added** -- :pull:`1165` - Concurrent renders - enable this experimental feature by setting - `REACTPY_FEATURE_CONCURRENT_RENDERING=true`. This should improve the overall - responsiveness of your app, particularly when handling larger renders that - would otherwise block faster renders from being processed. - +- :pull:`1165` - Allow concurrent renders of distinct components - enable this + experimental feature by setting `REACTPY_FEATURE_CONCURRENT_RENDERING=true`. + This should improve the overall responsiveness of your app, particularly when + handling larger renders that would otherwise block faster renders from being + processed. v1.0.2 ------ From 8c82bfbdb92e1a44ea1454430adc70887e503c19 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 18:46:59 -0800 Subject: [PATCH 09/22] effect function accepts stop event --- .../reactpy/reactpy/core/_life_cycle_hook.py | 62 ++++++++++--------- src/py/reactpy/reactpy/core/hooks.py | 24 +++---- src/py/reactpy/reactpy/core/layout.py | 12 ++-- src/py/reactpy/tests/test_core/test_hooks.py | 8 +-- 4 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 59f38e15b..0a1aa70c3 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -1,9 +1,8 @@ from __future__ import annotations import logging -from asyncio import gather -from collections.abc import Awaitable -from typing import Any, Callable, TypeVar +from asyncio import Event, Task, create_task, gather +from typing import Any, Callable, Protocol, TypeVar from anyio import Semaphore @@ -12,6 +11,12 @@ T = TypeVar("T") + +class EffectFunc(Protocol): + async def __call__(self, stop: Event) -> None: + ... + + logger = logging.getLogger(__name__) _HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) @@ -95,8 +100,9 @@ async def stop_effect(): "__weakref__", "_context_providers", "_current_state_index", - "_effect_cleanups", - "_effect_startups", + "_effect_funcs", + "_effect_tasks", + "_effect_stops", "_render_access", "_rendered_atleast_once", "_schedule_render_callback", @@ -117,8 +123,9 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () - self._effect_startups: list[Callable[[], Awaitable[None]]] = [] - self._effect_cleanups: list[Callable[[], Awaitable[None]]] = [] + self._effect_funcs: list[EffectFunc] = [] + self._effect_tasks: list[Task[None]] = [] + self._effect_stops: list[Event] = [] self._render_access = Semaphore(1) # ensure only one render at a time def schedule_render(self) -> None: @@ -138,19 +145,15 @@ def use_state(self, function: Callable[[], T]) -> T: self._current_state_index += 1 return result - def add_effect( - self, - start_effect: Callable[[], Awaitable[None]], - clean_effect: Callable[[], Awaitable[None]], - ) -> None: + def add_effect(self, effect_func: EffectFunc) -> None: """Add an effect to this hook - Effects are started when the component is done renderig and cleaned up when the - component is removed from the layout. Any other actions (e.g. re-running the - effect if a dependency changes) are the responsibility of the effect itself. + A task to run the effect is created when the component is done rendering. + When the component will be unmounted, the event passed to the effect is + triggered and the task is awaited. The effect should eventually halt after + the event is triggered. """ - self._effect_startups.append(start_effect) - self._effect_cleanups.append(clean_effect) + self._effect_funcs.append(effect_func) def set_context_provider(self, provider: ContextProviderType[Any]) -> None: self._context_providers[provider.type] = provider @@ -176,24 +179,25 @@ async def affect_component_did_render(self) -> None: async def affect_layout_did_render(self) -> None: """The layout completed a render""" - try: - await gather(*[start() for start in self._effect_startups]) - except Exception: - logger.exception("Error during effect startup") - finally: - self._effect_startups.clear() - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False + stop = Event() + self._effect_stops.append(stop) + self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) + self._effect_funcs.clear() + 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""" + for stop in self._effect_stops: + stop.set() + self._effect_stops.clear() try: - await gather(*[clean() for clean in self._effect_cleanups]) + await gather(*self._effect_tasks) except Exception: - logger.exception("Error during effect cleanup") + logger.exception("Error in effect") finally: - self._effect_cleanups.clear() + self._effect_tasks.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index eac5de817..4513dadef 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -152,24 +152,26 @@ def sync_function() -> _EffectCleanFunc | None: def clean_future() -> None: if not task.cancel(): - clean = task.result() - if clean is not None: - clean() + try: + clean = task.result() + except asyncio.CancelledError: + pass + else: + if clean is not None: + clean() return clean_future - async def start_effect() -> None: + async def effect(stop: asyncio.Event) -> None: if last_clean_callback.current is not None: last_clean_callback.current() last_clean_callback.current = None - last_clean_callback.current = sync_function() + clean = last_clean_callback.current = sync_function() + await stop.wait() + if clean is not None: + clean() - async def clean_effect() -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() - last_clean_callback.current = None - - return memoize(lambda: hook.add_effect(start_effect, clean_effect)) + return memoize(lambda: hook.add_effect(effect)) if function is not None: add_effect(function) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 0198c63f7..5a082d847 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -143,10 +143,14 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: if self._render_tasks else get_running_loop().create_future() ) - await wait( - (create_task(self._rendering_queue.ready()), render_completed), - return_when=FIRST_COMPLETED, - ) + queue_ready = create_task(self._rendering_queue.ready()) + try: + await wait((queue_ready, render_completed), return_when=FIRST_COMPLETED) + finally: + # Ensure we delete this task to avoid warnings that + # task was deleted without being awaited. + queue_ready.cancel() + if render_completed.done(): done, _ = await render_completed update_task: Task[LayoutUpdateMessage] = done.pop() diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index b91508549..ecc1ff68b 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -558,7 +558,7 @@ def bad_effect(): return reactpy.html.div() - with assert_reactpy_did_log(match_message=r"Error during effect startup"): + with assert_reactpy_did_log(match_message=r"Error in effect"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -584,7 +584,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Error during effect cleanup", + match_message=r"Error in effect", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -1003,7 +1003,7 @@ def bad_effect(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Error during effect startup", + match_message=r"Error in effect", error_type=ValueError, match_error="The error message", ): @@ -1246,7 +1246,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message="Error during effect cleanup", + match_message="Error in effect", error_type=ValueError, match_error="The error message", ): From 80d3b7a7a01cef961d51e3fadce74e674443d8d0 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 20:05:52 -0800 Subject: [PATCH 10/22] simplify concurrent render process --- .../reactpy/reactpy/core/_life_cycle_hook.py | 32 +++----- src/py/reactpy/reactpy/core/layout.py | 82 +++++++++---------- src/py/reactpy/tests/test_core/test_hooks.py | 19 +++-- src/py/reactpy/tests/test_core/test_layout.py | 47 +++++++++++ 4 files changed, 111 insertions(+), 69 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 0a1aa70c3..fa1c3e6f3 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -101,12 +101,12 @@ async def stop_effect(): "_context_providers", "_current_state_index", "_effect_funcs", - "_effect_tasks", "_effect_stops", + "_effect_tasks", "_render_access", "_rendered_atleast_once", "_schedule_render_callback", - "_schedule_render_later", + "_scheduled_render", "_state", "component", ) @@ -119,7 +119,7 @@ def __init__( ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render - self._schedule_render_later = False + self._scheduled_render = False self._rendered_atleast_once = False self._current_state_index = 0 self._state: tuple[Any, ...] = () @@ -129,10 +129,15 @@ def __init__( 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 + if self._scheduled_render: + return None + try: + self._schedule_render_callback() + except Exception: + msg = f"Failed to schedule render via {self._schedule_render_callback}" + logger.exception(msg) else: - self._schedule_render() + self._scheduled_render = True def use_state(self, function: Callable[[], T]) -> T: if not self._rendered_atleast_once: @@ -166,6 +171,7 @@ def get_context_provider( async def affect_component_will_render(self, component: ComponentType) -> None: """The component is about to render""" await self._render_access.acquire() + self._scheduled_render = False self.component = component self.set_current() @@ -183,9 +189,6 @@ async def affect_layout_did_render(self) -> None: self._effect_stops.append(stop) self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) self._effect_funcs.clear() - 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""" @@ -215,14 +218,3 @@ 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/layout.py b/src/py/reactpy/reactpy/core/layout.py index 5a082d847..08ac30467 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -3,11 +3,11 @@ import abc from asyncio import ( FIRST_COMPLETED, + CancelledError, Event, Queue, Task, create_task, - gather, get_running_loop, wait, ) @@ -27,6 +27,8 @@ from uuid import uuid4 from weakref import ref as weakref +from anyio import Semaphore + from reactpy.config import ( REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE, @@ -55,6 +57,7 @@ class Layout: "_event_handlers", "_rendering_queue", "_render_tasks", + "_render_tasks_ready", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", ) @@ -73,21 +76,28 @@ 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._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) + root_model_state = _new_root_model_state(self.root, self._schedule_render_task) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id - self._rendering_queue.put(root_id) - self._model_states_by_life_cycle_state_id = {root_id: root_model_state} + self._schedule_render_task(root_id) return self 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] - await gather(*self._render_tasks, return_exceptions=True) + + for t in self._render_tasks: + t.cancel() + try: + await t + except CancelledError: + pass + await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager @@ -137,40 +147,11 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov 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() - ) - queue_ready = create_task(self._rendering_queue.ready()) - try: - await wait((queue_ready, render_completed), return_when=FIRST_COMPLETED) - finally: - # Ensure we delete this task to avoid warnings that - # task was deleted without being awaited. - queue_ready.cancel() - - 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)) - ) + await self._render_tasks_ready.acquire() + done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED) + update_task: Task[LayoutUpdateMessage] = done.pop() + self._render_tasks.remove(update_task) + return update_task.result() async def _create_layout_update( self, old_state: _ModelState @@ -403,7 +384,7 @@ async def _render_model_children( index, key, child, - self._rendering_queue.put, + self._schedule_render_task, ) elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type @@ -415,7 +396,7 @@ async def _render_model_children( index, key, child, - self._rendering_queue.put, + self._schedule_render_task, ) else: new_child_state = _update_component_model_state( @@ -423,7 +404,7 @@ async def _render_model_children( new_state, index, child, - self._rendering_queue.put, + self._schedule_render_task, ) await self._render_component( exit_stack, old_child_state, new_child_state, child @@ -458,7 +439,7 @@ async def _render_model_children_without_old_state( 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 + new_state, index, key, child, self._schedule_render_task ) await self._render_component(exit_stack, None, child_state, child) else: @@ -479,6 +460,21 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount.extend(model_state.children_by_key.values()) + def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: + if not REACTPY_FEATURE_CONCURRENT_RENDERING.current: + self._rendering_queue.put(lcs_id) + return None + try: + model_state = self._model_states_by_life_cycle_state_id[lcs_id] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{lcs_id!r} - component already unmounted" + ) + else: + self._render_tasks.add(create_task(self._create_layout_update(model_state))) + self._render_tasks_ready.release() + def __repr__(self) -> str: return f"{type(self).__name__}({self.root})" diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index ecc1ff68b..128a70787 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -28,10 +28,15 @@ def SimpleComponentWithHook(): async def test_simple_stateful_component(): + index = 0 + + def set_index(x): + return None + @reactpy.component def SimpleStatefulComponent(): + nonlocal index, set_index index, set_index = reactpy.hooks.use_state(0) - set_index(index + 1) return reactpy.html.div(index) sse = SimpleStatefulComponent() @@ -45,6 +50,7 @@ def SimpleStatefulComponent(): "children": [{"tagName": "div", "children": ["0"]}], }, ) + set_index(index + 1) update_2 = await layout.render() assert update_2 == update_message( @@ -54,6 +60,7 @@ def SimpleStatefulComponent(): "children": [{"tagName": "div", "children": ["1"]}], }, ) + set_index(index + 1) update_3 = await layout.render() assert update_3 == update_message( @@ -1026,13 +1033,13 @@ def SetStateDuringRender(): async with Layout(SetStateDuringRender()) as layout: await layout.render() - assert render_count.current == 1 - await layout.render() - assert render_count.current == 2 - # there should be no more renders to perform + # we expect a second render to be triggered in the background + await poll(lambda: render_count.current).until_equals(2) + + # there should be no more renders that happen with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(layout.render(), timeout=0.1) + await poll(lambda: render_count.current).until_equals(3, timeout=0.1) @pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index d1140543d..ce2ac81a1 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -22,6 +22,7 @@ ) from reactpy.utils import Ref from tests.tooling import select +from tests.tooling.aio import Event from tests.tooling.common import event_message, update_message from tests.tooling.hooks import use_force_render, use_toggle from tests.tooling.layout import layout_runner @@ -1250,3 +1251,49 @@ def App(): c, c_info = find_element(tree, select.id_equals("C")) assert c_info.path == (0, 1, 0) assert c["attributes"]["color"] == "blue" + + +async def test_concurrent_renders(): + child_1_hook = HookCatcher() + child_2_hook = HookCatcher() + child_1_rendered = Event() + child_2_rendered = Event() + child_1_render_count = Ref(0) + child_2_render_count = Ref(0) + + @component + def outer(): + return html._(child_1(), child_2()) + + @component + @child_1_hook.capture + def child_1(): + child_1_rendered.set() + child_1_render_count.current += 1 + + @component + @child_2_hook.capture + def child_2(): + child_2_rendered.set() + child_2_render_count.current += 1 + + async with Layout(outer()) as layout: + await layout.render() + + # clear render events and counts + child_1_rendered.clear() + child_2_rendered.clear() + child_1_render_count.current = 0 + child_2_render_count.current = 0 + + # we schedule two renders but expect only one + child_1_hook.latest.schedule_render() + child_1_hook.latest.schedule_render() + child_2_hook.latest.schedule_render() + child_2_hook.latest.schedule_render() + + await child_1_rendered.wait() + await child_2_rendered.wait() + + assert child_1_render_count.current == 1 + assert child_2_render_count.current == 1 From bfb0d5c2cc67291c600bceeb7d9890113c7562fb Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 20:16:49 -0800 Subject: [PATCH 11/22] test serial renders too --- src/py/reactpy/reactpy/_option.py | 7 +++++- src/py/reactpy/tests/test_core/test_layout.py | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/_option.py b/src/py/reactpy/reactpy/_option.py index 09d0304a9..1db0857e3 100644 --- a/src/py/reactpy/reactpy/_option.py +++ b/src/py/reactpy/reactpy/_option.py @@ -68,6 +68,10 @@ def current(self) -> _O: def current(self, new: _O) -> None: self.set_current(new) + @current.deleter + def current(self) -> None: + self.unset() + def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]: """Register a callback that will be triggered when this option changes""" if not self.mutable: @@ -123,7 +127,8 @@ def unset(self) -> None: msg = f"{self} cannot be modified after initial load" raise TypeError(msg) old = self.current - delattr(self, "_current") + if hasattr(self, "_current"): + delattr(self, "_current") if self.current != old: for sub_func in self._subscribers: sub_func(self.current) diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index ce2ac81a1..19cdf07d1 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -2,6 +2,7 @@ import gc import random import re +from unittest.mock import patch from weakref import finalize from weakref import ref as weakref @@ -9,7 +10,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_FEATURE_CONCURRENT_RENDERING from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout @@ -20,6 +21,7 @@ assert_reactpy_did_log, capture_reactpy_logs, ) +from reactpy.testing.common import poll from reactpy.utils import Ref from tests.tooling import select from tests.tooling.aio import Event @@ -29,6 +31,12 @@ from tests.tooling.select import element_exists, find_element +@pytest.fixture(autouse=True, params=[True, False]) +def concurrent_rendering(request): + with patch.object(REACTPY_FEATURE_CONCURRENT_RENDERING, "current", request.param): + yield request.param + + @pytest.fixture(autouse=True) def no_logged_errors(): with capture_reactpy_logs() as logs: @@ -832,17 +840,19 @@ def some_effect(): async with reactpy.Layout(Root()) as layout: await layout.render() - assert effects == ["mount x"] + await poll(lambda: effects).until_equals(["mount x"]) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y"] + await poll(lambda: effects).until_equals(["mount x", "unmount x", "mount y"]) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"] + await poll(lambda: effects).until_equals( + ["mount x", "unmount x", "mount y", "unmount y", "mount x"] + ) async def test_layout_does_not_copy_element_children_by_key(): @@ -1253,7 +1263,10 @@ def App(): assert c["attributes"]["color"] == "blue" -async def test_concurrent_renders(): +async def test_concurrent_renders(concurrent_rendering): + if not concurrent_rendering: + raise pytest.skip("Concurrent rendering not enabled") + child_1_hook = HookCatcher() child_2_hook = HookCatcher() child_1_rendered = Event() From e9fd21e936a29fe69076c3348eb1c7a51e393330 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 21:57:31 -0700 Subject: [PATCH 12/22] remove ready event --- src/py/reactpy/reactpy/core/layout.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 08ac30467..afe8280e7 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -4,7 +4,6 @@ from asyncio import ( FIRST_COMPLETED, CancelledError, - Event, Queue, Task, create_task, @@ -709,23 +708,15 @@ def __init__(self) -> None: 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: value = await self._queue.get() self._pending.remove(value) - if not self._pending: - self._ready.clear() return value From 847277f64dbcff1a3d97807f047fcb1bde1938d1 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 3 Dec 2023 19:31:19 -0700 Subject: [PATCH 13/22] fix doc example --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index fa1c3e6f3..172daf160 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -69,13 +69,10 @@ class LifeCycleHook: # and save state or add effects current_hook().use_state(lambda: ...) - async def start_effect(): + async def my_effect(stop_event): ... - async def stop_effect(): - ... - - current_hook().add_effect(start_effect, stop_effect) + current_hook().add_effect(my_effect) finally: await hook.affect_component_did_render() From fb4478f307cbdf3f0e1f9bbbb189ccd17e70deeb Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 3 Dec 2023 19:42:21 -0700 Subject: [PATCH 14/22] add docstrings --- .../reactpy/reactpy/core/_life_cycle_hook.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 172daf160..ea5e6d634 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -32,7 +32,19 @@ def current_hook() -> LifeCycleHook: class LifeCycleHook: - """Defines the life cycle of a layout component. + """An object which manages the "life cycle" of a layout component. + + The "life cycle" of a component is the set of events which occur from the time + a component is first rendered until it is removed from the layout. The life cycle + is ultimately driven by the layout itself, but components can "hook" into those + events to perform actions. Components gain access to their own life cycle hook + by calling :func:`current_hook`. They can then perform actions such as: + + 1. Adding state via :meth:`use_state` + 2. Adding effects via :meth:`add_effect` + 3. Setting or getting context providers via + :meth:`LifeCycleHook.set_context_provider` and + :meth:`get_context_provider` respectively. 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 @@ -137,6 +149,12 @@ def schedule_render(self) -> None: self._scheduled_render = True def use_state(self, function: Callable[[], T]) -> T: + """Add state to this hook + + If this hook has not yet rendered, the state is appended to the state tuple. + Otherwise, the state is retrieved from the tuple. This allows state to be + preserved across renders. + """ if not self._rendered_atleast_once: # since we're not initialized yet we're just appending state result = function() @@ -158,11 +176,21 @@ def add_effect(self, effect_func: EffectFunc) -> None: self._effect_funcs.append(effect_func) def set_context_provider(self, provider: ContextProviderType[Any]) -> None: + """Set a context provider for this hook + + The context provider will be used to provide state to any child components + of this hook's component which request a context provider of the same type. + """ self._context_providers[provider.type] = provider def get_context_provider( self, context: Context[T] ) -> ContextProviderType[T] | None: + """Get a context provider for this hook of the given type + + The context provider will have been set by a parent component. If no provider + is found, ``None`` is returned. + """ return self._context_providers.get(context) async def affect_component_will_render(self, component: ComponentType) -> None: From 3c7a496f46ac9c4b3f578e1244a32e6362b6e1a8 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 4 Dec 2023 00:53:39 -0700 Subject: [PATCH 15/22] use function scope async fixtures --- src/py/reactpy/pyproject.toml | 2 +- src/py/reactpy/tests/conftest.py | 20 ++-- src/py/reactpy/tests/test_backend/test_all.py | 1 - src/py/reactpy/tests/tooling/loop.py | 91 ------------------- 4 files changed, 10 insertions(+), 104 deletions(-) delete mode 100644 src/py/reactpy/tests/tooling/loop.py diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 87fa7e036..fd6df8cf6 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -80,7 +80,7 @@ pre-install-command = "hatch build --hooks-only" dependencies = [ "coverage[toml]>=6.5", "pytest", - "pytest-asyncio>=0.17", + "pytest-asyncio>=0.23", "pytest-mock", "pytest-rerunfailures", "pytest-timeout", diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index d76f94f79..224db1ce7 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -18,7 +18,8 @@ capture_reactpy_logs, clear_reactpy_web_modules_dir, ) -from tests.tooling.loop import open_event_loop + +REACTPY_FEATURE_CONCURRENT_RENDERING.current = True def pytest_addoption(parser: Parser) -> None: @@ -30,22 +31,19 @@ def pytest_addoption(parser: Parser) -> None: ) -REACTPY_FEATURE_CONCURRENT_RENDERING.current = True - - @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: yield display -@pytest.fixture(scope="session") +@pytest.fixture async def server(): async with BackendFixture() as server: yield server -@pytest.fixture(scope="session") +@pytest.fixture async def page(browser): pg = await browser.new_page() pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) @@ -55,18 +53,18 @@ async def page(browser): await pg.close() -@pytest.fixture(scope="session") +@pytest.fixture async def browser(pytestconfig: Config): async with async_playwright() as pw: yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) @pytest.fixture(scope="session") -def event_loop(): +def event_loop_policy(): if os.name == "nt": # nocov - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - with open_event_loop() as loop: - yield loop + return asyncio.WindowsProactorEventLoopPolicy() + else: + return asyncio.DefaultEventLoopPolicy() @pytest.fixture(autouse=True) diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py index d697e5d3f..dc8ec1284 100644 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ b/src/py/reactpy/tests/test_backend/test_all.py @@ -14,7 +14,6 @@ @pytest.fixture( params=[*list(all_implementations()), default_implementation], ids=lambda imp: imp.__name__, - scope="module", ) async def display(page, request): imp: BackendType = request.param diff --git a/src/py/reactpy/tests/tooling/loop.py b/src/py/reactpy/tests/tooling/loop.py deleted file mode 100644 index f9e100981..000000000 --- a/src/py/reactpy/tests/tooling/loop.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import threading -import time -from asyncio import wait_for -from collections.abc import Iterator -from contextlib import contextmanager - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT - - -@contextmanager -def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: - """Open a new event loop and cleanly stop it - - Args: - as_current: whether to make this loop the current loop in this thread - """ - loop = asyncio.new_event_loop() - try: - if as_current: - asyncio.set_event_loop(loop) - loop.set_debug(True) - yield loop - finally: - try: - _cancel_all_tasks(loop, as_current) - if as_current: - loop.run_until_complete( - wait_for( - loop.shutdown_asyncgens(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - loop.run_until_complete( - wait_for( - loop.shutdown_default_executor(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - finally: - if as_current: - asyncio.set_event_loop(None) - start = time.time() - while loop.is_running(): - if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current: - msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds" - raise TimeoutError(msg) - time.sleep(0.1) - loop.close() - - -def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: - to_cancel = asyncio.all_tasks(loop) - if not to_cancel: - return - - done = threading.Event() - count = len(to_cancel) - - def one_task_finished(future): - nonlocal count - count -= 1 - if count == 0: - done.set() - - for task in to_cancel: - loop.call_soon_threadsafe(task.cancel) - task.add_done_callback(one_task_finished) - - if is_current: - loop.run_until_complete( - wait_for( - asyncio.gather(*to_cancel, return_exceptions=True), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - elif not done.wait(timeout=3): # user was responsible for cancelling all tasks - msg = "Could not stop event loop in time" - raise TimeoutError(msg) - - for task in to_cancel: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "unhandled exception during event loop shutdown", - "exception": task.exception(), - "task": task, - } - ) From cd9f5273d4696c745be678cf2aaa930679984481 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 4 Dec 2023 01:14:43 -0700 Subject: [PATCH 16/22] fix flaky test --- src/py/reactpy/reactpy/testing/common.py | 2 +- src/py/reactpy/tests/test_core/test_hooks.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index c799a24ff..c1eb18ba5 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -67,7 +67,7 @@ async def until( break elif (time.time() - started_at) > timeout: # nocov msg = f"Expected {description} after {timeout} seconds - last value was {result!r}" - raise TimeoutError(msg) + raise asyncio.TimeoutError(msg) async def until_is( self, diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 128a70787..fa6acafd1 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -1037,9 +1037,11 @@ def SetStateDuringRender(): # we expect a second render to be triggered in the background await poll(lambda: render_count.current).until_equals(2) - # there should be no more renders that happen - with pytest.raises(asyncio.TimeoutError): - await poll(lambda: render_count.current).until_equals(3, timeout=0.1) + # give an opportunity for a render to happen if it were to. + await asyncio.sleep(0.1) + + # however, we don't expect any more renders + assert render_count.current == 2 @pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") From 8477156590103992cbd52f9b8c70928bffa46d2e Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 17:00:34 -0700 Subject: [PATCH 17/22] rename config option --- docs/source/about/changelog.rst | 9 ++++----- src/py/reactpy/reactpy/config.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 6 +++--- src/py/reactpy/tests/conftest.py | 4 ++-- src/py/reactpy/tests/test_core/test_layout.py | 4 ++-- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 8a1afd544..d874a470f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -30,11 +30,10 @@ Unreleased **Added** -- :pull:`1165` - Allow concurrent renders of distinct components - enable this - experimental feature by setting `REACTPY_FEATURE_CONCURRENT_RENDERING=true`. - This should improve the overall responsiveness of your app, particularly when - handling larger renders that would otherwise block faster renders from being - processed. +- :pull:`1165` - Allow concurrent renders of discrete component tree - enable this + experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This should improve + the overall responsiveness of your app, particularly when handling larger renders + that would otherwise block faster renders from being processed. v1.0.2 ------ diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 698bf4d9e..8ea6aed03 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -81,7 +81,7 @@ def boolean(value: str | bool | int) -> bool: ) """A default timeout for testing utilities in ReactPy""" -REACTPY_FEATURE_CONCURRENT_RENDERING = Option( +REACTPY_ASYNC_RENDERING = Option( "REACTPY_CONCURRENT_RENDERING", default=False, mutable=True, diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index afe8280e7..d59ab31eb 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -29,9 +29,9 @@ from anyio import Semaphore from reactpy.config import ( + REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE, - REACTPY_FEATURE_CONCURRENT_RENDERING, ) from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( @@ -125,7 +125,7 @@ async def deliver(self, event: LayoutEventMessage) -> None: ) async def render(self) -> LayoutUpdateMessage: - if REACTPY_FEATURE_CONCURRENT_RENDERING.current: + if REACTPY_ASYNC_RENDERING.current: return await self._concurrent_render() else: # nocov return await self._serial_render() @@ -460,7 +460,7 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount.extend(model_state.children_by_key.values()) def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: - if not REACTPY_FEATURE_CONCURRENT_RENDERING.current: + if not REACTPY_ASYNC_RENDERING.current: self._rendering_queue.put(lcs_id) return None try: diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index 224db1ce7..743d67f02 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -9,7 +9,7 @@ from playwright.async_api import async_playwright from reactpy.config import ( - REACTPY_FEATURE_CONCURRENT_RENDERING, + REACTPY_ASYNC_RENDERING, REACTPY_TESTING_DEFAULT_TIMEOUT, ) from reactpy.testing import ( @@ -19,7 +19,7 @@ clear_reactpy_web_modules_dir, ) -REACTPY_FEATURE_CONCURRENT_RENDERING.current = True +REACTPY_ASYNC_RENDERING.current = True def pytest_addoption(parser: Parser) -> None: diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 19cdf07d1..9f27727df 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -10,7 +10,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_FEATURE_CONCURRENT_RENDERING +from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout @@ -33,7 +33,7 @@ @pytest.fixture(autouse=True, params=[True, False]) def concurrent_rendering(request): - with patch.object(REACTPY_FEATURE_CONCURRENT_RENDERING, "current", request.param): + with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param): yield request.param From fc8e688e54b10698ec9f8f70e4c5344ca38f83a3 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 17:08:15 -0700 Subject: [PATCH 18/22] move effect kick-off into component did render --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index ea5e6d634..e45b62d2a 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -202,18 +202,19 @@ async def affect_component_will_render(self, component: ComponentType) -> None: async def affect_component_did_render(self) -> None: """The component completed a render""" - self.unset_current() + stop = Event() + self._effect_stops.append(stop) + self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) + self._effect_funcs.clear() self._rendered_atleast_once = True self._current_state_index = 0 self._render_access.release() del self.component + self.unset_current() async def affect_layout_did_render(self) -> None: """The layout completed a render""" - stop = Event() - self._effect_stops.append(stop) - self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) - self._effect_funcs.clear() + pass async def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" From 8559c7b5bd2b20f5e515473911cc02983ef60f5c Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 23:10:43 -0700 Subject: [PATCH 19/22] move effect start to back to layout render --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index e45b62d2a..ea5e6d634 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -202,19 +202,18 @@ async def affect_component_will_render(self, component: ComponentType) -> None: async def affect_component_did_render(self) -> None: """The component completed a render""" - stop = Event() - self._effect_stops.append(stop) - self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) - self._effect_funcs.clear() + self.unset_current() self._rendered_atleast_once = True self._current_state_index = 0 self._render_access.release() del self.component - self.unset_current() async def affect_layout_did_render(self) -> None: """The layout completed a render""" - pass + stop = Event() + self._effect_stops.append(stop) + self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) + self._effect_funcs.clear() async def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" From 1b828ba38a0c57aa1106435a5b7a476807ad2375 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 23:12:25 -0700 Subject: [PATCH 20/22] try 3.x again --- .github/workflows/.hatch-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index eb37fc6ab..1b21e4202 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -16,7 +16,7 @@ on: python-version-array: required: false type: string - default: '["3.11"]' + default: '["3.x"]' node-registry-url: required: false type: string From 6d969ec59a9db9c62cf49fbb282604d39372ac1b Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 23:18:11 -0700 Subject: [PATCH 21/22] require tracerite 1.1.1 --- src/py/reactpy/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index fd6df8cf6..67189808b 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -45,6 +45,8 @@ starlette = [ sanic = [ "sanic >=21", "sanic-cors", + "tracerite>=1.1.1", + "setuptools", "uvicorn[standard] >=0.19.0", ] fastapi = [ From 6036048012c1f877f576d3e7669c0f6a09f5f3c5 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Thu, 7 Dec 2023 23:28:47 -0700 Subject: [PATCH 22/22] fix docs build --- .github/workflows/check.yml | 83 +++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index af768579c..d370ea129 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,45 +1,48 @@ name: check on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * 0" + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * 0" jobs: - test-py-cov: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-py" - lint-py: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "lint-py" - test-py-matrix: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0} {1}" - hatch-run: "test-py --no-cov" - runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version-array: '["3.9", "3.10", "3.11"]' - test-docs: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-docs" - test-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "test-js" - lint-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "lint-js" + test-py-cov: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "test-py" + lint-py: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "lint-py" + test-py-matrix: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0} {1}" + hatch-run: "test-py --no-cov" + runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' + python-version-array: '["3.9", "3.10", "3.11"]' + test-docs: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "test-docs" + # as of Dec 2023 lxml does have wheels for 3.12 + # https://bugs.launchpad.net/lxml/+bug/2040440 + python-version-array: '["3.11"]' + test-js: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "{1}" + hatch-run: "test-js" + lint-js: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "{1}" + hatch-run: "lint-js"