Concurrent Renders (#1165)

* initial work on concurrent renders

* concurrent renders

* limit to 3.11

* fix docs

* update changelog

* simpler add_effect interface

* improve docstring

* better changelog description

* effect function accepts stop event

* simplify concurrent render process

* test serial renders too

* remove ready event

* fix doc example

* add docstrings

* use function scope async fixtures

* fix flaky test

* rename config option

* move effect kick-off into component did render

* move effect start to back to layout render

* try 3.x again

* require tracerite 1.1.1

* fix docs build
This commit is contained in:
Ryan Morshead 2023-12-09 09:11:46 -07:00 committed by GitHub
parent 701e462f61
commit 341a4925fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 683 additions and 567 deletions

View file

@ -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.x"]'
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 }}

View file

@ -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"

View file

@ -28,6 +28,12 @@ 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` - 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
------

View file

@ -45,6 +45,8 @@ starlette = [
sanic = [
"sanic >=21",
"sanic-cors",
"tracerite>=1.1.1",
"setuptools",
"uvicorn[standard] >=0.19.0",
]
fastapi = [
@ -80,7 +82,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",

View file

@ -68,6 +68,10 @@ class Option(Generic[_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 @@ class Option(Generic[_O]):
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)

View file

@ -4,7 +4,8 @@ from collections.abc import MutableMapping
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)

View file

@ -80,3 +80,11 @@ REACTPY_TESTING_DEFAULT_TIMEOUT = Option(
validator=float,
)
"""A default timeout for testing utilities in ReactPy"""
REACTPY_ASYNC_RENDERING = Option(
"REACTPY_CONCURRENT_RENDERING",
default=False,
mutable=True,
validator=boolean,
)
"""Whether to render components concurrently. This is currently an experimental feature."""

View file

@ -0,0 +1,245 @@
from __future__ import annotations
import logging
from asyncio import Event, Task, create_task, gather
from typing import Any, Callable, Protocol, TypeVar
from anyio import Semaphore
from reactpy.core._thread_local import ThreadLocal
from reactpy.core.types import ComponentType, Context, ContextProviderType
T = TypeVar("T")
class EffectFunc(Protocol):
async def __call__(self, stop: Event) -> None:
...
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:
"""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
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_hook import LifeCycleHook
from reactpy.core.hooks import current_hook
# 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: ...)
async def my_effect(stop_event):
...
current_hook().add_effect(my_effect)
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_stops",
"_effect_tasks",
"_render_access",
"_rendered_atleast_once",
"_schedule_render_callback",
"_scheduled_render",
"_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._scheduled_render = False
self._rendered_atleast_once = False
self._current_state_index = 0
self._state: tuple[Any, ...] = ()
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:
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._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()
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: EffectFunc) -> None:
"""Add an effect to this hook
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_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:
"""The component is about to render"""
await self._render_access.acquire()
self._scheduled_render = False
self.component = component
self.set_current()
async def affect_component_did_render(self) -> None:
"""The component completed a render"""
self.unset_current()
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"""
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"""
for stop in self._effect_stops:
stop.set()
self._effect_stops.clear()
try:
await gather(*self._effect_tasks)
except Exception:
logger.exception("Error in effect")
finally:
self._effect_tasks.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

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Sequence
from collections.abc import Coroutine, Sequence
from logging import getLogger
from types import FunctionType
from typing import (
@ -9,7 +9,6 @@ from typing import (
Any,
Callable,
Generic,
NewType,
Protocol,
TypeVar,
cast,
@ -19,8 +18,8 @@ from typing import (
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:
@ -96,7 +95,9 @@ class _CurrentState(Generic[_Type]):
_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"
@ -147,25 +148,30 @@ def use_effect(
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 clean is not None:
clean()
if not task.cancel():
try:
clean = task.result()
except asyncio.CancelledError:
pass
else:
if clean is not None:
clean()
return clean_future
def 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
clean = last_clean_callback.current = sync_function()
await stop.wait()
if clean is not None:
hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean)
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 +218,8 @@ def create_context(default_value: _Type) -> Context[_Type]:
*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 +231,6 @@ def create_context(default_value: _Type) -> Context[_Type]:
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 +249,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 +263,14 @@ class ContextProvider(Generic[_Type]):
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 +489,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.

View file

@ -1,10 +1,18 @@
from __future__ import annotations
import abc
import asyncio
from asyncio import (
FIRST_COMPLETED,
CancelledError,
Queue,
Task,
create_task,
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 +26,14 @@ from typing import (
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 anyio import Semaphore
from reactpy.config import (
REACTPY_ASYNC_RENDERING,
REACTPY_CHECK_VDOM_SPEC,
REACTPY_DEBUG_MODE,
)
from reactpy.core._life_cycle_hook import LifeCycleHook
from reactpy.core.types import (
ComponentType,
EventHandlerDict,
@ -41,6 +55,8 @@ class Layout:
"root",
"_event_handlers",
"_rendering_queue",
"_render_tasks",
"_render_tasks_ready",
"_root_life_cycle_state_id",
"_model_states_by_life_cycle_state_id",
)
@ -58,21 +74,30 @@ class Layout:
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]
self._unmount_model_states([root_model_state])
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
del self._event_handlers
@ -100,6 +125,12 @@ class Layout:
)
async def render(self) -> LayoutUpdateMessage:
if REACTPY_ASYNC_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 +142,27 @@ class Layout:
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
return await self._create_layout_update(model_state)
def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage:
async def _concurrent_render(self) -> LayoutUpdateMessage:
"""Await the next available render. This will block until a component is updated"""
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
) -> 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 +170,9 @@ class Layout:
"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 +182,8 @@ class Layout:
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 +192,7 @@ class Layout:
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 +204,7 @@ class Layout:
),
}
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 +225,9 @@ class Layout:
],
}
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 +242,7 @@ class Layout:
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 +309,9 @@ class Layout:
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 +321,12 @@ class Layout:
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 +340,7 @@ class Layout:
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 +356,7 @@ class Layout:
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 +369,9 @@ class Layout:
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:
@ -344,19 +383,19 @@ class Layout:
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
):
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,
index,
key,
child,
self._rendering_queue.put,
self._schedule_render_task,
)
else:
new_child_state = _update_component_model_state(
@ -364,20 +403,20 @@ class Layout:
new_state,
index,
child,
self._rendering_queue.put,
self._schedule_render_task,
)
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 +433,18 @@ class Layout:
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
new_state, index, key, child, self._schedule_render_task
)
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,10 +455,25 @@ class Layout:
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())
def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None:
if not REACTPY_ASYNC_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})"
@ -538,6 +592,7 @@ class _ModelState:
__slots__ = (
"__weakref__",
"_parent_ref",
"_render_semaphore",
"children_by_key",
"index",
"key",
@ -649,11 +704,9 @@ _Type = TypeVar("_Type")
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()
def put(self, value: _Type) -> None:
@ -662,10 +715,7 @@ class _ThreadSafeQueue(Generic[_Type]):
self._loop.call_soon_threadsafe(self._queue.put_nowait, value)
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)
return value

View file

@ -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"

View file

@ -13,8 +13,8 @@ from weakref import ref
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:
@ -67,7 +67,7 @@ class poll(Generic[_R]): # noqa: N801
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,

View file

@ -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,

View file

@ -8,14 +8,18 @@ from _pytest.config import Config
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_ASYNC_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
REACTPY_ASYNC_RENDERING.current = True
def pytest_addoption(parser: Parser) -> None:
@ -33,13 +37,13 @@ async def display(server, page):
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)
@ -49,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)

View file

@ -14,7 +14,6 @@ from reactpy.testing import BackendFixture, DisplayFixture, poll
@pytest.fixture(
params=[*list(all_implementations()), default_implementation],
ids=lambda imp: imp.__name__,
scope="module",
)
async def display(page, request):
imp: BackendType = request.param

View file

@ -30,6 +30,11 @@ async def test_automatic_reconnect(browser: Browser):
),
)
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 @@ async def test_automatic_reconnect(browser: Browser):
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 @@ async def test_automatic_reconnect(browser: Browser):
# 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 @@ async def test_style_can_be_changed(display: DisplayFixture):
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):

View file

@ -5,12 +5,8 @@ import pytest
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
@ -32,10 +28,15 @@ async def test_must_be_rendering_in_layout_to_use_hooks():
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()
@ -49,6 +50,7 @@ async def test_simple_stateful_component():
"children": [{"tagName": "div", "children": ["0"]}],
},
)
set_index(index + 1)
update_2 = await layout.render()
assert update_2 == update_message(
@ -58,6 +60,7 @@ async def test_simple_stateful_component():
"children": [{"tagName": "div", "children": ["1"]}],
},
)
set_index(index + 1)
update_3 = await layout.render()
assert update_3 == update_message(
@ -278,18 +281,18 @@ async def test_double_set_state(display: DisplayFixture):
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():
@ -562,7 +565,7 @@ async def test_error_in_effect_is_gracefully_handled(caplog):
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
@ -588,7 +591,7 @@ async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled():
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:
@ -1007,7 +1010,7 @@ async def test_error_in_layout_effect_cleanup_is_gracefully_handled():
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",
):
@ -1030,13 +1033,15 @@ async def test_set_state_during_render():
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
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(layout.render(), timeout=0.1)
# we expect a second render to be triggered in the background
await poll(lambda: render_count.current).until_equals(2)
# 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")
@ -1240,16 +1245,17 @@ 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(
match_message="Component post-render effect .*? failed",
match_message="Error in effect",
error_type=ValueError,
match_error="The error message",
):

View file

@ -2,6 +2,7 @@ import asyncio
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 pytest
import reactpy
from reactpy import html
from reactpy.config import REACTPY_DEBUG_MODE
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
@ -20,14 +21,22 @@ from reactpy.testing import (
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
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
from tests.tooling.select import element_exists, find_element
@pytest.fixture(autouse=True, params=[True, False])
def concurrent_rendering(request):
with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param):
yield request.param
@pytest.fixture(autouse=True)
def no_logged_errors():
with capture_reactpy_logs() as logs:
@ -164,7 +173,7 @@ async def test_nested_component_layout():
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 +631,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):
@ -831,17 +840,19 @@ async def test_elements_and_components_with_the_same_key_can_be_interchanged():
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():
@ -1250,3 +1261,52 @@ async def test_ensure_model_path_udpates():
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(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()
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

View file

@ -5,10 +5,12 @@ from typing import Any
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 test_dispatcher_handles_more_than_one_event_at_a_time():
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 test_dispatcher_handles_more_than_one_event_at_a_time():
recv_queue.get,
)
)
try:
await did_render.wait()
await recv_queue.put(event_message(blocked_handler.target))
await will_block.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()
task.cancel()
await recv_queue.put(event_message(non_blocked_handler.target))
await second_event_did_execute.wait()
finally:
task.cancel()

View file

@ -0,0 +1,16 @@
from __future__ import annotations
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,
)

View file

@ -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,
}
)