Skip to content

Commit

Permalink
concurrent renders
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed Nov 28, 2023
1 parent 1bc558b commit dd37697
Show file tree
Hide file tree
Showing 7 changed files with 84 additions and 47 deletions.
25 changes: 15 additions & 10 deletions src/py/reactpy/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ class LifeCycleHook:
"__weakref__",
"_context_providers",
"_current_state_index",
"_effect_generators",
"_pending_effects",
"_render_access",
"_rendered_atleast_once",
"_running_effects",
"_schedule_render_callback",
"_schedule_render_later",
"_state",
Expand All @@ -109,7 +110,8 @@ def __init__(
self._rendered_atleast_once = False
self._current_state_index = 0
self._state: tuple[Any, ...] = ()
self._effect_generators: list[AsyncGenerator[None, None]] = []
self._pending_effects: list[AsyncGenerator[None, None]] = []
self._running_effects: list[AsyncGenerator[None, None]] = []
self._render_access = Semaphore(1) # ensure only one render at a time

def schedule_render(self) -> None:
Expand All @@ -131,7 +133,7 @@ def use_state(self, function: Callable[[], T]) -> T:

def add_effect(self, effect_func: Callable[[], AsyncGenerator[None, None]]) -> None:
"""Add an effect to this hook"""
self._effect_generators.append(effect_func())
self._pending_effects.append(effect_func())

def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
self._context_providers[provider.type] = provider
Expand All @@ -150,29 +152,32 @@ async def affect_component_will_render(self, component: ComponentType) -> None:
async def affect_component_did_render(self) -> None:
"""The component completed a render"""
self.unset_current()
del self.component
self._rendered_atleast_once = True
self._current_state_index = 0
self._render_access.release()

async def affect_layout_did_render(self) -> None:
"""The layout completed a render"""
try:
await gather(*[g.asend(None) for g in self._effect_generators])
await gather(*[g.asend(None) for g in self._pending_effects])
self._running_effects.extend(self._pending_effects)
except Exception:
logger.exception("Error during effect execution")
logger.exception("Error during effect startup")
finally:
self._pending_effects.clear()
if self._schedule_render_later:
self._schedule_render()
self._schedule_render_later = False
del self.component

async def affect_component_will_unmount(self) -> None:
"""The component is about to be removed from the layout"""
try:
await gather(*[g.aclose() for g in self._effect_generators])
await gather(*[g.aclose() for g in self._running_effects])
except Exception:
logger.exception("Error during effect cancellation")
logger.exception("Error during effect cleanup")
finally:
self._effect_generators.clear()
self._running_effects.clear()

def set_current(self) -> None:
"""Set this hook as the active hook in this thread
Expand All @@ -192,7 +197,7 @@ def unset_current(self) -> None:
raise RuntimeError("Hook stack is in an invalid state") # nocov

def _is_rendering(self) -> bool:
return self._render_access.value != 0
return self._render_access.value == 0

def _schedule_render(self) -> None:
try:
Expand Down
16 changes: 12 additions & 4 deletions src/py/reactpy/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,20 @@ async def effect() -> AsyncGenerator[None, None]:
if last_clean_callback.current is not None:
last_clean_callback.current()

clean = last_clean_callback.current = sync_function()
cleaned = False
clean = sync_function()

def callback() -> None:
nonlocal cleaned
if clean and not cleaned:
cleaned = True
clean()

last_clean_callback.current = callback
try:
yield
finally:
if clean is not None:
clean()
callback()

return memoize(lambda: hook.add_effect(effect))

Expand Down Expand Up @@ -266,7 +274,7 @@ def render(self) -> VdomDict:
return {"tagName": "", "children": self.children}

def __repr__(self) -> str:
return f"{type(self).__name__}({self.type})"
return f"ContextProvider({self.type})"


_ActionType = TypeVar("_ActionType")
Expand Down
22 changes: 12 additions & 10 deletions src/py/reactpy/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ def 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")

async with AsyncExitStack() as exit_stack:
server = await exit_stack.enter_async_context(BackendFixture(port=port))
display = await exit_stack.enter_async_context(
Expand All @@ -38,11 +43,10 @@ def SomeComponent():

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
Expand All @@ -57,13 +61,7 @@ def SomeComponent():
# 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
Expand Down Expand Up @@ -98,11 +96,15 @@ def ButtonWithChangingColor():

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):
Expand Down
20 changes: 10 additions & 10 deletions src/py/reactpy/tests/test_core/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,18 +274,18 @@ def double_set_state(event):
first = await display.page.wait_for_selector("#first")
second = await display.page.wait_for_selector("#second")

assert (await first.get_attribute("data-value")) == "0"
assert (await second.get_attribute("data-value")) == "0"
await poll(first.get_attribute, "data-value").until_equals("0")
await poll(second.get_attribute, "data-value").until_equals("0")

await button.click()

assert (await first.get_attribute("data-value")) == "1"
assert (await second.get_attribute("data-value")) == "1"
await poll(first.get_attribute, "data-value").until_equals("1")
await poll(second.get_attribute, "data-value").until_equals("1")

await button.click()

assert (await first.get_attribute("data-value")) == "2"
assert (await second.get_attribute("data-value")) == "2"
await poll(first.get_attribute, "data-value").until_equals("2")
await poll(second.get_attribute, "data-value").until_equals("2")


async def test_use_effect_callback_occurs_after_full_render_is_complete():
Expand Down Expand Up @@ -558,7 +558,7 @@ def bad_effect():

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 during effect startup"):
async with reactpy.Layout(ComponentWithEffect()) as layout:
await layout.render() # no error

Expand All @@ -584,7 +584,7 @@ def bad_cleanup():
return reactpy.html.div()

with assert_reactpy_did_log(
match_message=r"Pre-unmount effect .*? failed",
match_message=r"Error during effect cleanup",
error_type=ValueError,
):
async with reactpy.Layout(OuterComponent()) as layout:
Expand Down Expand Up @@ -1003,7 +1003,7 @@ def bad_effect():
return reactpy.html.div()

with assert_reactpy_did_log(
match_message=r"post-render effect .*? failed",
match_message=r"Error during effect startup",
error_type=ValueError,
match_error="The error message",
):
Expand Down Expand Up @@ -1246,7 +1246,7 @@ def bad_cleanup():
return reactpy.html.div()

with assert_reactpy_did_log(
match_message="Component post-render effect .*? failed",
match_message="Error during effect cleanup",
error_type=ValueError,
match_error="The error message",
):
Expand Down
4 changes: 2 additions & 2 deletions src/py/reactpy/tests/test_core/test_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def make_child_model(state):
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():
Expand Down Expand Up @@ -622,7 +622,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):
Expand Down
30 changes: 19 additions & 11 deletions src/py/reactpy/tests/test_core/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
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"
Expand Down Expand Up @@ -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()
Expand All @@ -114,6 +117,10 @@ async def block_forever():
async def handle_event():
second_event_did_execute.set()

@use_effect
def set_did_render():
did_render.set()

return reactpy.html.div(
reactpy.html.button({"on_click": block_forever}),
reactpy.html.button({"on_click": handle_event}),
Expand All @@ -129,11 +136,12 @@ async def handle_event():
recv_queue.get,
)
)

await recv_queue.put(event_message(blocked_handler.target))
await will_block.wait()

await recv_queue.put(event_message(non_blocked_handler.target))
await second_event_did_execute.wait()

task.cancel()
try:
await did_render.wait()
await recv_queue.put(event_message(blocked_handler.target))
await will_block.wait()

await recv_queue.put(event_message(non_blocked_handler.target))
await second_event_did_execute.wait()
finally:
task.cancel()
14 changes: 14 additions & 0 deletions src/py/reactpy/tests/tooling/aio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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,
)

0 comments on commit dd37697

Please sign in to comment.