Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Concurrent Renders #1165

Merged
merged 22 commits into from
Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 54 additions & 54 deletions .github/workflows/.hatch-run.yml
Original file line number Diff line number Diff line change
@@ -1,59 +1,59 @@
name: hatch-run

on:
workflow_call:
inputs:
job-name:
required: true
type: string
hatch-run:
required: true
type: string
runs-on-array:
required: false
type: string
default: '["ubuntu-latest"]'
python-version-array:
required: false
type: string
default: '["3.x"]'
node-registry-url:
required: false
type: string
default: ""
secrets:
node-auth-token:
required: false
pypi-username:
required: false
pypi-password:
required: false
workflow_call:
inputs:
job-name:
required: true
type: string
hatch-run:
required: true
type: string
runs-on-array:
required: false
type: string
default: '["ubuntu-latest"]'
python-version-array:
required: false
type: string
default: '["3.11"]'
node-registry-url:
required: false
type: string
default: ""
secrets:
node-auth-token:
required: false
pypi-username:
required: false
pypi-password:
required: false

jobs:
hatch:
name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-version-array) }}
runs-on: ${{ fromJson(inputs.runs-on-array) }}
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "14.x"
registry-url: ${{ inputs.node-registry-url }}
- name: Pin NPM Version
run: npm install -g npm@8.19.3
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install Python Dependencies
run: pip install hatch poetry
- name: Run Scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
PYPI_USERNAME: ${{ secrets.pypi-username }}
PYPI_PASSWORD: ${{ secrets.pypi-password }}
run: hatch run ${{ inputs.hatch-run }}
hatch:
name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-version-array) }}
runs-on: ${{ fromJson(inputs.runs-on-array) }}
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "14.x"
registry-url: ${{ inputs.node-registry-url }}
- name: Pin NPM Version
run: npm install -g npm@8.19.3
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install Python Dependencies
run: pip install hatch poetry
- name: Run Scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
PYPI_USERNAME: ${{ secrets.pypi-username }}
PYPI_PASSWORD: ${{ secrets.pypi-password }}
run: hatch run ${{ inputs.hatch-run }}
7 changes: 7 additions & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ Unreleased
- :pull:`1118` - `module_from_template` is broken with a recent release of `requests`
- :pull:`1131` - `module_from_template` did not work when using Flask backend

**Added**

- :pull:`1165` - Allow concurrent renders of distinct components - enable this
rmorshea marked this conversation as resolved.
Show resolved Hide resolved
experimental feature by setting `REACTPY_FEATURE_CONCURRENT_RENDERING=true`.
rmorshea marked this conversation as resolved.
Show resolved Hide resolved
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
------
Expand Down
7 changes: 6 additions & 1 deletion src/py/reactpy/reactpy/_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ def current(self) -> _O:
def current(self, new: _O) -> None:
self.set_current(new)

@current.deleter
def current(self) -> None:
self.unset()

def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]:
"""Register a callback that will be triggered when this option changes"""
if not self.mutable:
Expand Down Expand Up @@ -123,7 +127,8 @@ def unset(self) -> None:
msg = f"{self} cannot be modified after initial load"
raise TypeError(msg)
old = self.current
delattr(self, "_current")
if hasattr(self, "_current"):
delattr(self, "_current")
if self.current != old:
for sub_func in self._subscribers:
sub_func(self.current)
Expand Down
3 changes: 2 additions & 1 deletion src/py/reactpy/reactpy/backend/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import Any

from reactpy.backend.types import Connection, Location
from reactpy.core.hooks import Context, create_context, use_context
from reactpy.core.hooks import create_context, use_context
from reactpy.core.types import Context

# backend implementations should establish this context at the root of an app
ConnectionContext: Context[Connection[Any] | None] = create_context(None)
Expand Down
8 changes: 8 additions & 0 deletions src/py/reactpy/reactpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
validator=float,
)
"""A default timeout for testing utilities in ReactPy"""

REACTPY_FEATURE_CONCURRENT_RENDERING = Option(
"REACTPY_CONCURRENT_RENDERING",
default=False,
mutable=True,
validator=boolean,
)
"""Whether to render components concurrently. This is currently an experimental feature."""
220 changes: 220 additions & 0 deletions src/py/reactpy/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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:
rmorshea marked this conversation as resolved.
Show resolved Hide resolved
"""Defines the life cycle of a layout component.
rmorshea marked this conversation as resolved.
Show resolved Hide resolved

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 start_effect():
...

async def stop_effect():
...

current_hook().add_effect(start_effect, stop_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:
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:
self._context_providers[provider.type] = provider

def get_context_provider(
self, context: Context[T]
) -> ContextProviderType[T] | None:
return self._context_providers.get(context)

async def affect_component_will_render(self, component: ComponentType) -> None:
"""The component is about to render"""
await self._render_access.acquire()
self._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)
rmorshea marked this conversation as resolved.
Show resolved Hide resolved
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
Loading
Loading