-
-
Notifications
You must be signed in to change notification settings - Fork 324
Concurrent Renders #1165
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
Merged
Merged
Concurrent Renders #1165
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
1bc558b
initial work on concurrent renders
rmorshea dd37697
concurrent renders
rmorshea 41c2431
limit to 3.11
rmorshea f681e1b
fix docs
rmorshea 387dc05
update changelog
rmorshea a4fc2f5
simpler add_effect interface
rmorshea b9595ff
improve docstring
rmorshea 24575fc
better changelog description
rmorshea 8c82bfb
effect function accepts stop event
rmorshea 80d3b7a
simplify concurrent render process
rmorshea bfb0d5c
test serial renders too
rmorshea e9fd21e
remove ready event
rmorshea 847277f
fix doc example
rmorshea fb4478f
add docstrings
rmorshea 3c7a496
use function scope async fixtures
rmorshea cd9f527
fix flaky test
rmorshea 8477156
rename config option
rmorshea fc8e688
move effect kick-off into component did render
rmorshea 8559c7b
move effect start to back to layout render
rmorshea 1b828ba
try 3.x again
rmorshea 6d969ec
require tracerite 1.1.1
rmorshea 6036048
fix docs build
rmorshea File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.