From a6dd14102dc30a21b39a3e859aa19e4384aabfbf Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 27 Nov 2023 15:44:24 -0800 Subject: [PATCH] concurrent renders --- .../reactpy/reactpy/core/_life_cycle_hook.py | 25 +++++++++------- src/py/reactpy/reactpy/core/hooks.py | 16 +++++++--- src/py/reactpy/tests/test_client.py | 22 +++++++------- src/py/reactpy/tests/test_core/test_hooks.py | 20 ++++++------- src/py/reactpy/tests/test_core/test_layout.py | 4 +-- src/py/reactpy/tests/test_core/test_serve.py | 30 ++++++++++++------- src/py/reactpy/tests/tooling/aio.py | 14 +++++++++ 7 files changed, 84 insertions(+), 47 deletions(-) create mode 100644 src/py/reactpy/tests/tooling/aio.py diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index cf92f2a1e..81262c599 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -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", @@ -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: @@ -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 @@ -150,7 +152,6 @@ 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() @@ -158,21 +159,25 @@ async def affect_component_did_render(self) -> None: 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 @@ -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: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 8cc22ba8c..8d9d89629 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -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)) @@ -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") diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py index 3c7250e48..a9ff10a89 100644 --- a/src/py/reactpy/tests/test_client.py +++ b/src/py/reactpy/tests/test_client.py @@ -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( @@ -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 @@ -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 @@ -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): diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 6647d9b08..b91508549 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -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(): @@ -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 @@ -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: @@ -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", ): @@ -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", ): diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 215e89137..d1140543d 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -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(): @@ -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): diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 64be0ec8b..9b22ee866 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -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" @@ -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 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}), @@ -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() diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py new file mode 100644 index 000000000..eb3d762bf --- /dev/null +++ b/src/py/reactpy/tests/tooling/aio.py @@ -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, + )