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:
parent
701e462f61
commit
341a4925fd
21 changed files with 683 additions and 567 deletions
108
.github/workflows/.hatch-run.yml
vendored
108
.github/workflows/.hatch-run.yml
vendored
|
@ -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 }}
|
||||
|
|
83
.github/workflows/check.yml
vendored
83
.github/workflows/check.yml
vendored
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
------
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."""
|
||||
|
|
245
src/py/reactpy/reactpy/core/_life_cycle_hook.py
Normal file
245
src/py/reactpy/reactpy/core/_life_cycle_hook.py
Normal 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
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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",
|
||||
):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
16
src/py/reactpy/tests/tooling/aio.py
Normal file
16
src/py/reactpy/tests/tooling/aio.py
Normal 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,
|
||||
)
|
|
@ -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,
|
||||
}
|
||||
)
|
Loading…
Reference in a new issue