Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
concurrent renders
  • Loading branch information
rmorshea committed Nov 28, 2023
commit dd37697eedd5107b7212640ad30f390f1dd05b7c
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,
)