diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index b312869e4..2b354d9c3 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.x"]' + default: '["3.11"]' node-registry-url: required: false type: string diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 32a3df2dc..3dae9d6ae 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -28,6 +28,14 @@ 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:`1093` - Better async effects (see :ref:`Async Effects`) +- :pull:`1093` - Support concurrent renders - multiple components are now able to render + simultaneously. This is a significant change to the underlying rendering logic and + should be considered experimental. You can enable this feature by setting + ``REACTPY_FEATURE_CONCURRENT_RENDER=1`` when running ReactPy. + v1.0.2 ------ diff --git a/docs/source/conf.py b/docs/source/conf.py index 08addad8d..0e1877b75 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -322,6 +322,7 @@ "sanic": ("https://sanic.readthedocs.io/en/latest/", None), "tornado": ("https://www.tornadoweb.org/en/stable/", None), "flask": ("https://flask.palletsprojects.com/en/1.1.x/", None), + "anyio": ("https://anyio.readthedocs.io/en/stable", None), } # -- Options for todo extension ---------------------------------------------- diff --git a/docs/source/reference/hooks-api.rst b/docs/source/reference/hooks-api.rst index ca8123e85..63162642c 100644 --- a/docs/source/reference/hooks-api.rst +++ b/docs/source/reference/hooks-api.rst @@ -89,7 +89,9 @@ Use Effect .. code-block:: - use_effect(did_render) + @use_effect + def did_render(): + ... # imperative or state mutating logic The ``use_effect`` hook accepts a function which may be imperative, or mutate state. The function will be called immediately after the layout has fully updated. @@ -117,12 +119,11 @@ then closing a connection: .. code-block:: + @use_effect def establish_connection(): connection = open_connection() return lambda: close_connection(connection) - use_effect(establish_connection) - The clean-up function will be run before the component is unmounted or, before the next effect is triggered when the component re-renders. You can :ref:`conditionally fire events ` to avoid triggering them each @@ -141,40 +142,76 @@ example, imagine that we had an effect that connected to a ``url`` state variabl url, set_url = use_state("https://example.com") + @use_effect def establish_connection(): connection = open_connection(url) return lambda: close_connection(connection) - use_effect(establish_connection) - Here, a new connection will be established whenever a new ``url`` is set. +.. warning:: + + A component will be unable to render until all its outstanding effects have been + cleaned up. As such, it's best to keep cleanup logic as simple as possible and/or + to impose a time limit. + Async Effects ............. A behavior unique to ReactPy's implementation of ``use_effect`` is that it natively -supports ``async`` functions: +supports ``async`` effects. Async effect functions may either be an async function +or an async generator. If your effect doesn't need to do any cleanup, then you can +simply write an async function. .. code-block:: - async def non_blocking_effect(): - resource = await do_something_asynchronously() - return lambda: blocking_close(resource) + @use_effect + async def my_async_effect(): + await do_something() - use_effect(non_blocking_effect) +However, if you need to do any cleanup, then you'll need to write an async generator +instead. The generator should run the effect logic in a ``try`` block, ``yield`` control +back to ReactPy, and then run the cleanup logic in a ``finally`` block: +.. code-block:: -There are **three important subtleties** to note about using asynchronous effects: + @use_effect + async def my_async_effect(): + try: + await effect_logic() + yield + finally: + await cleanup_logic() -1. The cleanup function must be a normal synchronous function. +When a component is re-rendered or unmounted the effect will be cancelled if it is still +running. This will typically happen for long-lived effects. One example might be an +effect that opens a connection and then responds to messages for the lifetime of the +connection: -2. Asynchronous effects which do not complete before the next effect is created - following a re-render will be cancelled. This means an - :class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect. +.. code-block:: + + @use_effect + async def my_async_effect(): + conn = await open_connection() + try: + while True: + msg = await conn.recv() + await handle_message(msg) + finally: + await close_connection(conn) + +.. warning:: + + Because an effect can be cancelled at any time, it's possible that the cleanup logic + will run before all of the effect logic has finished. For example, in the code + above, we exclude ``conn = await open_connection()`` from the ``try`` block because + if the effect is cancelled before the connection is opened, then we don't need to + close it. + +.. note:: -3. An asynchronous effect may occur any time after the update which added this effect - and before the next effect following a subsequent update. + We don't need a yield statement here because the effect only ends when it's cancelled. Manual Effect Conditions diff --git a/pyproject.toml b/pyproject.toml index 3cf94e23f..fd0b28f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "semver >=2, <3", "twine", "pre-commit", + # required by some packages during install + "setuptools", ] [tool.hatch.envs.default.scripts] @@ -130,8 +132,9 @@ ignore = [ "PLR0915", ] unfixable = [ - # Don't touch unused imports + # Don't touch unused imports or unused variables "F401", + "F841", ] [tool.ruff.isort] diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 87fa7e036..47d0c0980 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", + # required by some packages during install + "setuptools", ] [project.optional-dependencies] all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] @@ -92,8 +94,8 @@ dependencies = [ "jsonpointer", ] [tool.hatch.envs.default.scripts] -test = "playwright install && pytest {args:tests}" -test-cov = "playwright install && coverage run -m pytest {args:tests}" +test = "playwright install chromium && pytest {args:tests}" +test-cov = "playwright install chromium && coverage run -m pytest {args:tests}" cov-report = [ # "- coverage combine", "coverage report", 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..5b083b456 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -80,3 +80,19 @@ def boolean(value: str | bool | int) -> bool: validator=float, ) """A default timeout for testing utilities in ReactPy""" + +REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT = Option( + "REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT", + 30.0, + mutable=False, + validator=float, +) +"""The default amount of time to wait for an effect to complete""" + +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..0c6bb380f --- /dev/null +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +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") + +StopEffect = Callable[[], Coroutine[None, None, None]] +StartEffect = Callable[[], Coroutine[None, None, StopEffect]] + +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_funcs", + "_effect_starts", + "_effect_stops", + "_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_starts: list[StartEffect] = [] + self._effect_stops: list[StopEffect] = [] + 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, start_effect: StartEffect) -> None: + """Add an effect to this hook""" + self._effect_starts.append(start_effect) + + 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""" + self._effect_stops.extend( + await asyncio.gather(*[start() for start in self._effect_starts]) + ) + self._effect_starts.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 asyncio.gather(*[stop() for stop in self._effect_stops]) + except Exception: # nocov + logger.exception("Error during effect cancellation") + finally: + self._effect_stops.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..78dcab002 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,7 +1,15 @@ from __future__ import annotations -import asyncio -from collections.abc import Awaitable, Sequence +import inspect +import warnings +from asyncio import CancelledError, Event, create_task +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Coroutine, + Generator, + Sequence, +) from logging import getLogger from types import FunctionType from typing import ( @@ -9,7 +17,6 @@ Any, Callable, Generic, - NewType, Protocol, TypeVar, cast, @@ -19,15 +26,14 @@ 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 StopEffect, current_hook +from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: # make flake8 think that this variable exists ellipsis = type(...) - __all__ = [ "use_state", "use_effect", @@ -94,32 +100,38 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: self.dispatch = dispatch -_EffectCleanFunc: TypeAlias = "Callable[[], None]" -_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" -_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" +_SyncGeneratorEffect: TypeAlias = Callable[[], Generator[None, None, None]] +_SyncFunctionEffect: TypeAlias = Callable[[], Callable[[], None] | None] +_SyncEffect: TypeAlias = _SyncGeneratorEffect | _SyncFunctionEffect + +_AsyncGeneratorEffect: TypeAlias = Callable[[], AsyncGenerator[None, None]] +_AsyncFunctionEffect: TypeAlias = Callable[ + [], Coroutine[None, None, Callable[[], None] | None] +] +_AsyncEffect: TypeAlias = _AsyncGeneratorEffect | _AsyncFunctionEffect +_Effect: TypeAlias = _SyncEffect | _AsyncEffect @overload def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None]: +) -> Callable[[_Effect], None]: ... @overload def use_effect( - function: _EffectApplyFunc, + function: _Effect, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectApplyFunc | None = None, + function: _Effect | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: +) -> Callable[[_Effect], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -129,49 +141,111 @@ def use_effect( Dependencies for the effect. The effect will only trigger if the identity of any value in the given sequence changes (i.e. their :func:`id` is different). By default these are inferred based on local variables that are - referenced by the given function. + referenced by the given function. If ``None``, then the effect runs on every + render. Returns: - If not function is provided, a decorator. Otherwise ``None``. + If no function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + stop_last_effect: Ref[StopEffect | None] = use_ref(None) + + def add_effect(function: _Effect) -> None: + effect_func = _cast_async_effect(function) + + async def start_effect() -> StopEffect: + if stop_last_effect.current is not None: + await stop_last_effect.current() + + stop = Event() + + async def run_effect() -> None: + effect_gen = effect_func() + # start running the effect + effect_task = create_task( + cast(Coroutine[None, None, None], effect_gen.asend(None)) + ) + # wait for re-render or unmount + await stop.wait() + # signal effect to stop (no-op if already complete) + effect_task.cancel() + # wait for effect to halt + try: + await effect_task + except CancelledError: + pass + # wait for effect cleanup + await effect_gen.aclose() + + effect_task = create_task(run_effect()) + + async def stop_effect() -> None: + stop.set() + try: + await effect_task + except Exception: + logger.exception("Error in effect") + + stop_last_effect.current = stop_effect + + return stop_effect + + return memoize(lambda: hook.add_effect(start_effect)) - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) - - def sync_function() -> _EffectCleanFunc | None: - future = asyncio.ensure_future(async_function()) + if function is not None: + add_effect(function) + return None + else: + return add_effect - def clean_future() -> None: - if not future.cancel(): - clean = future.result() - if clean is not None: - clean() - return clean_future +def _cast_async_effect(function: Callable[..., Any]) -> _AsyncGeneratorEffect: + if inspect.isasyncgenfunction(function): + async_generator_effect = function + elif inspect.iscoroutinefunction(function): + async_function_effect = cast(_AsyncFunctionEffect, function) - def effect() -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() + async def async_generator_effect() -> AsyncIterator[None]: + task = create_task(async_function_effect()) + try: + yield + finally: + cleanup = await task + if cleanup is not None: + warnings.warn( + "Async effect returned a cleanup function - use an async " + "generator instead by yielding inside a try/finally block. " + "This will be an error in a future version of ReactPy.", + DeprecationWarning, + stacklevel=3, + ) + cleanup() + + elif inspect.isgeneratorfunction(function): + sync_generator_effect = cast(_SyncGeneratorEffect, function) + + async def async_generator_effect() -> AsyncIterator[None]: + gen = sync_generator_effect() + gen.send(None) + try: + yield + finally: + gen.close() - clean = last_clean_callback.current = sync_function() - if clean is not None: - hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) + else: + sync_function_effect = cast(_SyncFunctionEffect, function) - return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) + async def async_generator_effect() -> AsyncIterator[None]: + cleanup = sync_function_effect() + try: + yield + finally: + if cleanup is not None: + cleanup() - if function is not None: - add_effect(function) - return None - else: - return add_effect + return async_generator_effect def use_debug_value( @@ -212,8 +286,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 +299,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 +317,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,14 +331,14 @@ 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) return {"tagName": "", "children": self.children} def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" + return f"ContextProvider({self.type})" _ActionType = TypeVar("_ActionType") @@ -495,231 +557,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..91c05aa69 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -20,6 +20,7 @@ from typing_extensions import TypeAlias, TypedDict _Type = TypeVar("_Type") +_Type_invariant = TypeVar("_Type_invariant", covariant=False) if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): @@ -233,3 +234,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_invariant]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type_invariant = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type_invariant]: + ... + + +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..8c1bdec75 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -8,14 +8,14 @@ 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, capture_reactpy_logs, clear_reactpy_web_modules_dir, ) -from tests.tooling.loop import open_event_loop +from tests.tooling.concurrency import open_event_loop def pytest_addoption(parser: Parser) -> None: @@ -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_client.py b/src/py/reactpy/tests/test_client.py index 3c7250e48..b93279f0a 100644 --- a/src/py/reactpy/tests/test_client.py +++ b/src/py/reactpy/tests/test_client.py @@ -42,7 +42,7 @@ def SomeComponent(): incr = await page.wait_for_selector("#incr") for i in range(3): - assert (await count.get_attribute("data-count")) == str(i) + await poll(count.get_attribute, "data-count").until_equals(str(i)) await incr.click() # the server is disconnected but the last view state is still shown @@ -102,7 +102,9 @@ def ButtonWithChangingColor(): 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 453d07c99..fd4fbde71 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -5,17 +5,14 @@ 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 from reactpy.utils import Ref from tests.tooling.common import DEFAULT_TYPE_DELAY, update_message +from tests.tooling.concurrency import WaitForEvent async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -278,18 +275,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(): @@ -305,7 +302,7 @@ def OuterComponent(): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def effect(): effect_triggered.current = True @@ -327,15 +324,15 @@ def CheckNoEffectYet(): async def test_use_effect_cleanup_occurs_before_next_effect(): component_hook = HookCatcher() cleanup_triggered = reactpy.Ref(False) - cleanup_triggered_before_next_effect = reactpy.Ref(False) + cleanup_triggered_before_next_effect = WaitForEvent() @reactpy.component @component_hook.capture def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) + @reactpy.use_effect(dependencies=None) def effect(): if cleanup_triggered.current: - cleanup_triggered_before_next_effect.current = True + cleanup_triggered_before_next_effect.set() def cleanup(): cleanup_triggered.current = True @@ -353,7 +350,7 @@ def cleanup(): await layout.render() assert cleanup_triggered.current - assert cleanup_triggered_before_next_effect.current + await cleanup_triggered_before_next_effect.wait() async def test_use_effect_cleanup_occurs_on_will_unmount(): @@ -374,7 +371,7 @@ def ComponentWithEffect(): component_did_render.current = True - @reactpy.hooks.use_effect + @reactpy.use_effect def effect(): def cleanup(): cleanup_triggered.current = True @@ -395,10 +392,11 @@ def cleanup(): assert cleanup_triggered_before_next_render.current -async def test_memoized_effect_on_recreated_if_dependencies_change(): +async def test_memoized_effect_is_recreated_if_dependencies_change(): component_hook = HookCatcher() set_state_callback = reactpy.Ref(None) - effect_run_count = reactpy.Ref(0) + effect_ran = WaitForEvent() + run_count = 0 first_value = 1 second_value = 2 @@ -408,31 +406,33 @@ async def test_memoized_effect_on_recreated_if_dependencies_change(): def ComponentWithMemoizedEffect(): state, set_state_callback.current = reactpy.hooks.use_state(first_value) - @reactpy.hooks.use_effect(dependencies=[state]) + @reactpy.use_effect(dependencies=[state]) def effect(): - effect_run_count.current += 1 + nonlocal run_count + effect_ran.set() + run_count += 1 return reactpy.html.div() async with reactpy.Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() - assert effect_run_count.current == 1 + await effect_ran.wait() + effect_ran.clear() component_hook.latest.schedule_render() await layout.render() - assert effect_run_count.current == 1 - set_state_callback.current(second_value) await layout.render() - assert effect_run_count.current == 2 + await effect_ran.wait() + effect_ran.clear() component_hook.latest.schedule_render() await layout.render() - assert effect_run_count.current == 2 + assert run_count == 2 async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): @@ -448,7 +448,7 @@ async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): def ComponentWithEffect(): state, set_state_callback.current = reactpy.hooks.use_state(first_value) - @reactpy.hooks.use_effect(dependencies=[state]) + @reactpy.use_effect(dependencies=[state]) def effect(): def cleanup(): cleanup_trigger_count.current += 1 @@ -473,65 +473,21 @@ def cleanup(): assert cleanup_trigger_count.current == 1 -async def test_use_async_effect(): - effect_ran = asyncio.Event() - - @reactpy.component - def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect - async def effect(): - effect_ran.set() - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - await asyncio.wait_for(effect_ran.wait(), 1) - - -async def test_use_async_effect_cleanup(): - component_hook = HookCatcher() - effect_ran = asyncio.Event() - cleanup_ran = asyncio.Event() - - @reactpy.component - @component_hook.capture - def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time - async def effect(): - effect_ran.set() - return cleanup_ran.set - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - - component_hook.latest.schedule_render() - - await layout.render() - - await asyncio.wait_for(cleanup_ran.wait(), 1) - - async def test_use_async_effect_cancel(caplog): component_hook = HookCatcher() - effect_ran = asyncio.Event() - effect_was_cancelled = asyncio.Event() - - event_that_never_occurs = asyncio.Event() + effect_ran = WaitForEvent() + effect_cleanup = WaitForEvent() @reactpy.component @component_hook.capture def ComponentWithLongWaitingEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time + @reactpy.use_effect(dependencies=None) # force this to run every time async def effect(): effect_ran.set() try: - await event_that_never_occurs.wait() - except asyncio.CancelledError: - effect_was_cancelled.set() - raise + yield + finally: + effect_cleanup.set() return reactpy.html.div() @@ -543,26 +499,20 @@ async def effect(): await layout.render() - await asyncio.wait_for(effect_was_cancelled.wait(), 1) - - # So I know we said the event never occurs but... to ensure the effect's future is - # cancelled before the test is cleaned up we need to set the event. This is because - # the cancellation doesn't propagate before the test is resolved which causes - # delayed log messages that impact other tests. - event_that_never_occurs.set() + await effect_cleanup.wait(1) async def test_error_in_effect_is_gracefully_handled(caplog): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def bad_effect(): msg = "Something went wong :(" raise ValueError(msg) 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 in effect"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -577,7 +527,7 @@ def OuterComponent(): @reactpy.component def ComponentWithEffect(): - @reactpy.hooks.use_effect + @reactpy.use_effect def ok_effect(): def bad_cleanup(): msg = "Something went wong :(" @@ -588,7 +538,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Pre-unmount effect .*? failed", + match_message=r"Error in effect", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -845,13 +795,13 @@ def bad_callback(): async def test_use_effect_automatically_infers_closure_values(): set_count = reactpy.Ref() - did_effect = asyncio.Event() + did_effect = WaitForEvent() @reactpy.component def CounterWithEffect(): count, set_count.current = reactpy.hooks.use_state(0) - @reactpy.hooks.use_effect + @reactpy.use_effect def some_effect_that_uses_count(): """should automatically trigger on count change""" _ = count # use count in this closure @@ -873,7 +823,7 @@ def some_effect_that_uses_count(): async def test_use_memo_automatically_infers_closure_values(): set_count = reactpy.Ref() - did_memo = asyncio.Event() + did_memo = WaitForEvent() @reactpy.component def CounterWithEffect(): @@ -999,15 +949,18 @@ async def test_error_in_layout_effect_cleanup_is_gracefully_handled(): @reactpy.component @component_hook.capture def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) # always run + @reactpy.use_effect(dependencies=None) # always run def bad_effect(): - msg = "The error message" - raise ValueError(msg) + def bad_cleanup(): + msg = "The error message" + raise ValueError(msg) + + return bad_cleanup return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"post-render effect .*? failed", + match_message=r"Error in effect", error_type=ValueError, match_error="The error message", ): @@ -1211,12 +1164,12 @@ def incr_effect_count(): async with reactpy.Layout(SomeComponent()) as layout: await layout.render() - assert effect_count.current == 1 + await poll(lambda: effect_count.current).until_equals(1) value.current = "string" # new string instance but same value hook.latest.schedule_render() await layout.render() # effect does not trigger - assert effect_count.current == 1 + await poll(lambda: effect_count.current).until_equals(1) async def test_use_state_named_tuple(): @@ -1234,26 +1187,69 @@ def some_component(): assert state.current.value == 2 -async def test_error_in_component_effect_cleanup_is_gracefully_handled(): - component_hook = HookCatcher() +async def test_slow_async_generator_effect_is_cancelled_and_cleaned_up(): + hook_catcher = HookCatcher() + + never = asyncio.Event() + did_run = WaitForEvent() + did_cancel = WaitForEvent() + did_cleanup = WaitForEvent() @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - hook = current_hook() + @hook_catcher.capture + def some_component(): + @use_effect(dependencies=None) + async def slow_effect(): + try: + did_run.set() + await never.wait() + yield + except asyncio.CancelledError: + did_cancel.set() + raise + finally: + # should be allowed to await in finally + await asyncio.sleep(0) + did_cleanup.set() - def bad_effect(): - raise ValueError("The error message") + async with reactpy.Layout(some_component()) as layout: + await layout.render() - hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) - return reactpy.html.div() + await did_run.wait() - with assert_reactpy_did_log( - match_message="Component post-render effect .*? failed", - error_type=ValueError, - match_error="The error message", - ): - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() # no error + hook_catcher.latest.schedule_render() + render_task = asyncio.create_task(layout.render()) + + await did_cancel.wait() + await did_cleanup.wait() + + await render_task + + +async def test_sync_generator_style_effect(): + hook_catcher = HookCatcher() + + did_run = WaitForEvent() + did_cleanup = WaitForEvent() + + @reactpy.component + @hook_catcher.capture + def some_component(): + @use_effect(dependencies=None) + def sync_generator_effect(): + try: + did_run.set() + yield + finally: + did_cleanup.set() + + async with reactpy.Layout(some_component()) as layout: + await layout.render() + + await did_run.wait() + + hook_catcher.latest.schedule_render() + render_task = asyncio.create_task(layout.render()) + + await did_cleanup.wait() + await render_task diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 215e89137..41c81cad5 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -20,6 +20,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.common import event_message, update_message @@ -828,20 +829,28 @@ def some_effect(): return reactpy.html.div(name) + poll_effects = poll(lambda: effects) + async with reactpy.Layout(Root()) as layout: await layout.render() - assert effects == ["mount x"] + await poll_effects.until_equals( + ["mount x"], + ) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y"] + await poll_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_effects.until_equals( + ["mount x", "unmount x", "mount y", "unmount y", "mount x"], + ) async def test_layout_does_not_copy_element_children_by_key(): diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 64be0ec8b..7690707f8 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -5,11 +5,13 @@ 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.common import event_message +from tests.tooling.concurrency import WaitForEvent EVENT_NAME = "on_event" STATIC_EVENT_HANDLER = StaticEventHandler() @@ -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 = WaitForEvent() + block_and_never_set = WaitForEvent() + will_block = WaitForEvent() + second_event_did_execute = WaitForEvent() 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/concurrency.py b/src/py/reactpy/tests/tooling/concurrency.py new file mode 100644 index 000000000..5391ede30 --- /dev/null +++ b/src/py/reactpy/tests/tooling/concurrency.py @@ -0,0 +1,98 @@ +import asyncio +import threading +import time +from asyncio import Event, wait_for +from collections.abc import Iterator +from contextlib import contextmanager + +from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT + + +class WaitForEvent(Event): + """Event where the wait method has a timeout.""" + + async def wait(self, timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current): + return await wait_for(super().wait(), timeout=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, + } + )