From 1bc558b03e9b0816c71f347dd276988aedb1d306 Mon Sep 17 00:00:00 2001
From: Ryan Morshead <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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 <ryan.morshead@gmail.com>
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"